SPEC_ANSWERHUB_LIVE.md
AnswerHub — the first real run — spec
What this covers: walking AnswerHub end to end on the tailnet with a
real browser and a real siao.ai account, and fixing everything that
walk finds. Also the three already-known AnswerHub defects, and the
sign-in page's visual alignment with the app family.
For what is actually built, deployed or reachable, see
ClaudeDocs/HANDOFF.md — status deliberately does not live here.
This spec builds on SPEC_ACCOUNTS.md (which owns the identity model)
and SPEC_ANSWERHUB_FRONTEND.md (which owns the restyle). It does not
supersede either. It supersedes nothing; it finishes something.
No issue tracker exists for this project, and no triage-label
vocabulary was available, so this spec is filed as a ClaudeDocs/SPEC_*
document — the same precedent SPEC_APPS.md recorded.
Problem Statement
AnswerHub's frontend restyle shipped, its credential store was deleted,
and it became an OIDC client of accounts.siao.ai. The container runs
on the tailnet and every page answers 200. /answerhub/login redirects
correctly to Logto's authorization endpoint with PKCE parameters
attached.
Nobody has ever completed a sign-in.
Every claim about AnswerHub's identity integration rests on unit tests,
Playwright tests against fixtures, and an HTTP probe that stops at the
redirect. The parts that only exist in the real deployment have never
run once: the token exchange against the live provider, the userinfo
call, first-sign-in provisioning of an AnswerHub profile, the member
number that provisioning mints, the session cookie surviving a return
visit, and RP-initiated logout actually ending the provider's session.
The same is true of everything downstream of being signed in — uploading
a submission, liking, commenting, editing a profile, and the entire
administrator surface, which has never been exercised by an account that
actually holds the ADMIN role.
Two documents also describe a system that no longer exists, which means anyone planning work from them plans it wrong:
NOTES_ACCOUNTS.mdrecords the sign-in experience as Logto's default username-and-password. It is not. It is email-identified, passwordless, verification-code sign-in with open registration, and the family's sage accent is already applied.HANDOFF.mdpresents "nobody can push" as a total blocker. Forapps-siao-aiit is not: an SSH bare mirror on the host is a working deploy path, already holding the newest commit, with only the host's checkout a commit behind.
Separately, three defects are known, recorded, and unfixed. Two of them
are correctness and security problems that this walk will run straight
into: profile links accept javascript: URLs, and a submission an
administrator has taken down still accepts likes and comments. The third
is that listing queries fetch whole rows, including each submission's
files blob, to render a title.
The like-and-comment defect deserves its own sentence, because it explains why the project's existing guard did not catch it. AnswerHub has a structural test that finds every file reading a content model and requires it to consult the visibility seam. That test matches reads. The like and comment routes never read a submission — they write to one directly. They were invisible to the guard built to catch exactly this class of bug.
Solution
Walk the whole product, on the real deployment, with a real browser, against the real identity provider — then fix what that produces.
The walk covers the identity loop twice (a freshly registered account, to
exercise first-sign-in provisioning, and the owner's own account, to
exercise the return path), then the ordinary user's actions, then the
administrator's, after promoting the owner's AnswerHub profile to
ADMIN. It stays on the tailnet. No DNS record, tunnel ingress rule or
certificate work is part of this. Sign-in is email plus verification
code; Google is deliberately left for the session that settles the public
domain name, so its redirect URI is registered once rather than twice.
Fixes land in one pass after the walk, not incrementally during it, so that the shape of the real problems is known before any of them is addressed. The bar for "in scope" is broad: anything broken, anything plainly wrong, and anything plainly unpleasant to use — missing loading states, unhelpful errors, bare empty states, form validation that gives no feedback, layouts that fail on a phone. Pure aesthetic refinement — spacing, weight, rhythm — is a separate round, so this one can finish.
The three known defects are all fixed. Two of them route through seams
that already exist rather than adding new checks: the like and comment
routes consult the visibility seam, and the structural guard is widened
so that writing against a submission counts as needing a visibility
decision, not only reading one. The javascript: URL hole gets the one
genuinely new seam in this spec — a protocol allow-list applied at the
point of rendering, because that covers rows already sitting in the
database as well as any future write path.
The Logto sign-in page is treated as part of AnswerHub's interface,
because a user really does land on it mid-flow and a page that does not
look like siao.ai is a failure of "connected up properly". Its visuals
and its Traditional Chinese copy are brought in line with the app
family. Its authentication methods are not touched, and Logto's
attribution stays.
Deployment goes through the SSH bare mirror, with a rollback commit recorded first.
User Stories
Signing in for the first time
- As a new visitor, I want to click a single sign-in control on
AnswerHub and arrive at
accounts.siao.ai, so that I never have to find a separate registration step. - As a new visitor, I want to register with only my email address, so that I am not asked to invent a password for a site that stores none.
- As a new visitor, I want the verification code email to arrive in
Traditional Chinese and name
siao.ai, so that I can tell it is the message I just asked for and not a phishing attempt. - As a new visitor, I want the verification code to be accepted and to land back on AnswerHub already signed in, so that the round trip feels like one action rather than a departure and a return.
- As a new visitor, I want an AnswerHub profile created for me the moment I first arrive, so that there is no second registration to complete before I can do anything.
- As a new visitor, I want that profile to carry an AnswerHub member number, so that I have the platform's own identifier from the start.
- As a signed-in user, I want the header to show that I am signed in and to offer my profile, so that I can confirm the sign-in actually took effect.
Staying signed in, and signing out
- As a signed-in user, I want to close the tab, come back, and still be signed in, so that routine use does not mean authenticating again.
- As a returning account holder, I want a second sign-in to find my existing AnswerHub profile rather than create a duplicate, so that my submissions, likes and member number stay attached to me.
- As an account holder whose email changed at the identity provider, I want AnswerHub's displayed address to follow it, so that what I see matches who I am.
- As a signed-in user, I want signing out to end the session at
accounts.siao.aitoo, so that the next sign-in genuinely asks who I am instead of completing silently. - As a signed-out user, I want to be able to sign in again immediately afterwards, so that signing out is not a one-way door within a session.
Contributing and reading
- As a signed-in user, I want to upload an answer and see it appear, so that contributing works rather than merely appearing to.
- As a signed-in user, I want the upload form to tell me what is wrong when it rejects my input, so that I am not left guessing at a form that silently refuses.
- As a signed-in user, I want a visible indication while an upload is in progress, so that I do not submit twice believing nothing happened.
- As any visitor, I want a submission's detail page to render its title, description and files, so that the thing I came to read is readable.
- As a signed-in user, I want to like a submission and see the count change, so that the control is real rather than decorative.
- As a signed-in user, I want to comment on a submission and see my comment, so that the discussion feature works.
- As a signed-in user, I want to edit my bio and social links and have them persist, so that my profile is mine.
- As any visitor, I want the homepage search to behave predictably and state its own limitations, so that an unavailable feature reads as intentional rather than broken.
- As any visitor, I want a page with nothing on it yet to say so in words, so that an empty list is not mistaken for a failure.
- As a visitor on a phone, I want every page in this walk to be usable at that width, so that the product is not desktop-only by accident.
Moderation
- As an administrator, I want my AnswerHub profile to carry the
ADMINrole while mysiao.aiidentity stays untouched, so that authorisation remains AnswerHub's own concern. - As an administrator, I want to see the review list of submissions, so that moderation has a working entry point.
- As an administrator, I want to hide a submission and have it vanish from the homepage, from search, and from its own detail page, so that a takedown is a takedown.
- As an administrator, I want a hidden submission to stop accepting likes, so that a taken-down item cannot keep accruing engagement.
- As an administrator, I want a hidden submission to stop accepting comments, so that a taken-down item cannot keep accumulating replies nobody can see.
- As an administrator, I want to ban an account and have that account lose its ability to upload, so that suspension has teeth.
- As an administrator, I want banning someone on AnswerHub to leave
their
siao.aiaccount and their access to other applications completely alone, so that one product's moderation decision does not become the family's. - As a non-administrator, I want the moderation surface to refuse me, so that the role means something.
Safety
- As any visitor, I want a link on someone's profile to be unable to execute code when I click it, so that reading a profile is safe.
- As any visitor, I want that protection to cover profiles saved before the protection existed, so that the fix is not only forward-looking.
- As a user editing my own profile, I want an unusable link to be rejected while I am still on the form, so that I find out at the time rather than discovering later that my link does nothing.
- As a developer adding a route that writes to a submission, I want the test suite to fail unless I have made a visibility decision, so that the next instance of this bug is caught by machinery rather than by an audit.
- As a developer adding a page that renders a user-supplied URL, I want the test suite to fail unless I have routed it through the allow-list, so that the protection cannot be quietly bypassed.
The sign-in page as part of the product
- As a visitor being sent to sign in, I want the page I land on to
carry
siao.ai's colors and typography, so that I can tell I have not been redirected somewhere hostile. - As a Traditional Chinese speaker, I want the sign-in page's wording to be in my language and to name the product, so that the handover between applications does not switch languages under me.
- As the owner, I want Logto's attribution to remain visible, so that the terms under which the software is given away are honoured.
- As the owner, I do not want the sign-in page's authentication methods changed in this pass, so that a visual fix cannot break the only way into the product.
Operating it
- As the owner, I want the code that runs on the host to be the code in the repository, so that the deployment is reproducible rather than incidental.
- As the owner, I want a recorded rollback point before the deploy, so that a bad build is one command away from being undone.
- As the owner, I want the documents describing this system to match it, so that the next session does not plan against a sign-in experience that was replaced.
- As the owner, I want the credentials exposed while investigating this work to be listed for rotation, so that an accidental disclosure is closed rather than forgotten.
- As the owner, I want the test account created during this walk to be identified clearly and its removal described, so that it does not become permanent fixture data nobody dares delete.
Implementation Decisions
The walk happens on the tailnet deployment, against the live Logto instance, in a real browser. Not
localhost, not a fixture, not a headless probe. The whole point is the parts that only exist in the real deployment.apps.siao.aiandaccounts.siao.aigain no DNS record, no tunnel ingress rule and no certificate work.That last sentence was overturned, in the same way Google's connector below was.
apps.siao.aiwas published on 2026-08-10 andaccounts.siao.aion 2026-08-13; both have DNS records and tunnel ingress rules today, and no certificate work was needed for either — the tunnel reaches the containers over the Tailscale interface and Cloudflare terminates TLS at its edge. Measured 2026-08-14: both answer from the internet, and/answerhub/logincompletes its redirect to the provider's public name. The scope note stands as a record of what this walk deliberately did not do; it is no longer a description of the deployment.Sign-in method is email plus verification code, the sign-in experience already configured. This spec planned to leave Google's connector disabled until a public name was settled, so that its redirect URI would only have to be registered once.
That decision was overturned: Google sign-in was enabled on 2026-08-10 with its redirect URI naming the tailnet host, which means it will need registering a second time when
accounts.siao.aiis published. Where it stands now isHANDOFF.md's to say; the reasoning above is kept because the trade-off it describes is the one that was knowingly paid.Two accounts are walked. A newly registered account exercises first-sign-in provisioning and member-number minting; the owner's own account exercises the return path, where an existing profile must be found rather than duplicated. The new account uses a plus-address of a mailbox the owner reads.
The administrator role is granted by writing
ADMINto the owner's AnswerHub profile row. This is a direct database write. It is correct that it is manual: role is AnswerHub's own concept and the identity provider has no say in it, and there is deliberately no self-service path to becoming an administrator.Fixes are applied in one pass after the walk completes, not interleaved with it. A walk interrupted by fixes produces a list shaped by the order things were found rather than by what is actually wrong.
Scope boundary for walk findings: broken, plainly wrong, and plainly unpleasant are all in. Missing loading states, uninformative errors, unstyled empty states, silent form validation and phone-width layout failures are named explicitly as in scope. Spacing, weight and typographic rhythm are out — a separate round, so this one terminates.
Like and comment routes consult the existing visibility seam. Neither currently reads the submission at all; both write to it directly. Each gains a read of the target submission and a
canViewSubmissiondecision before mutating. No new predicate is introduced — the seam that already answers "may this viewer see this submission?" answers "may this viewer attach something to it?" too. A submission the viewer may not see rejects the write.The structural guard is widened from reads to writes. Its content matcher currently recognises only submission and blog-post reads, which is precisely why the like and comment routes escaped it. It gains recognition of writes against a submission — the mutation itself, and the creation of child rows keyed by a submission — so that any future route touching a submission must either consult the seam, prove the viewer is an administrator, or be added to the exemption list with a defended reason. The exemption list stays empty unless something genuinely earns a place in it.
URL safety is enforced at render, and this is the only new seam. A single helper decides whether a user-supplied URL may become an
href, by allow-listing protocols rather than blocking known-bad ones. It sits at the render point, not at the write schema, for two reasons: rows already in the database were written before any validation existed, and a future write path would otherwise reopen the hole. A link that fails the check is not rendered as a link. The profile write schema is tightened in the same change, but only so that a user gets told at the time — it is feedback, not the boundary. The structural guard is extended to fail on a raw user-supplied value reaching anhref.Listing queries gain explicit field selection. Every listing that renders a summary stops fetching whole rows, and in particular stops fetching each submission's files blob to display a title. This changes query shape only; the visibility filter applied to each listing is unchanged, and the fields a listing renders are unchanged.
The sign-in page changes only its presentation. Colors, typography and Traditional Chinese copy are brought in line with the app family's token set; the primary color is already correct and is left alone. Authentication methods, identifiers, the registration mode and the multi-factor configuration are all untouched. Logto's attribution stays visible, per the standing decision. The existing custom stylesheet's own warning — that guessed selectors against Logto's internal class names could not be verified and produced a visible defect — is treated as binding: presentation goes through the supported branding path wherever one exists, and the stylesheet stays minimal.
Deployment path is the SSH bare mirror on the host, which already holds the newest commit: push, pull on the host, rebuild the container. A rollback commit is recorded before the rebuild. The Forgejo credential blocker recorded in the handoff does not apply to this repository, and the handoff is corrected to say so.
The designated remote for this repository remains undecided and is not decided here. This work uses the mirror.
Both superseded documents are corrected in the same session as the work: the sign-in experience description, and the claim that pushing is universally blocked. A correction that lives only in conversation is a correction that gets re-litigated.
Testing Decisions
A good test here asserts on externally observable behavior — what a page renders, what status code a route returns, what a database row contains after an action — never on how a component is built internally. This is the seam the whole existing suite already uses, and nothing about this work justifies a new one.
Prior art is the existing suite in this repository: the Playwright specs covering account, profile, submissions, admin, blog, knowledge and search, and the vitest unit tests over the pure functions in the AnswerHub library directory. Extend these files rather than starting new ones.
The visibility fix is tested behaviorally at the route level: with a submission taken down, a like attempt and a comment attempt each fail and leave the stored counts unchanged. Asserting the counts matters as much as asserting the status code — the bug's shape was a successful side effect, not a wrong response.
The structural guard tests itself by construction: it walks the application directory and fails on any file that touches content without a visibility decision. Its own existing self-checks — that the matcher still matches something, and that every exemption names a file that exists — are kept, because a structural test that silently stops matching is worse than no test. The widened matcher must be verified to actually flag the two offending routes before they are fixed; otherwise the widening is unproven.
URL safety gets unit tests on the allow-list helper covering accepted protocols, rejected script-bearing protocols, case and whitespace variations, and malformed input — plus one rendered-page assertion that a profile holding a hostile link does not render it as a clickable link. The unit tests are where the edge cases live; the page test proves the helper is actually wired in.
The field-selection change is covered by the tests that already exist. It is a query-shape change with no intended behavioral difference, so the existing listing and detail assertions passing unchanged is the evidence. No new test is written for it; a new test asserting which fields were selected would be testing the implementation.
Walk findings each get a test at the level the fix lands. A fix in a pure function gets a unit test; a fix in a page or route gets a Playwright assertion. Findings that are purely presentational and have no observable behavior get no test, and that is recorded rather than quietly skipped.
The done bar is typecheck, unit, end-to-end and a production build all passing, matching the standard the sibling repository was held to most recently. The suite's last measured result for this repository is stale and is re-measured rather than cited.
No accessibility or visual-snapshot coverage is added. The accessibility harness exists here but is wired into a single spec file, and there are no visual snapshots at all. Bringing this repository up to the family's stated verification convention is real work with its own scope, and folding it in would make this spec unfinishable. It is named here as a known gap, not silently omitted.
The browser walk itself is not automated. It is the manual verification that produces the findings; the tests that come out of it are what prevent regression. Recording which paths were walked, and what each produced, is part of the deliverable.
Out of Scope
- Public reachability. No DNS record for
apps.siao.aioraccounts.siao.ai, no tunnel ingress rule, no certificate covering a public name. The tunnel's existing posture is untouched. - Google sign-in. The connector is configured; enabling it in the sign-in experience and registering its redirect URI waits for the session that settles the public domain name.
- Changing authentication methods. No email connector work, no multi-factor policy changes, no passkey enrolment. Passkeys in particular are deliberately not enrolled against the tailnet host name, because they are bound to a domain and would be discarded when the public name arrives.
- Renaming the URL path, internal identifiers or test file names to match the product name. Still the deferred mechanical pass it has always been.
- Blog and knowledge base. Their code stays, unreachable from navigation. Neither is walked and neither is fixed.
- Live search results. The search bar's behavior is verified and its unavailable state is made to look intentional; wiring a real search backend is not part of this.
- Aesthetic refinement. Spacing, weight, typographic rhythm and visual polish are a separate round.
- Accessibility and visual-snapshot coverage across the application, as described above.
- Deciding this repository's designated remote.
- Any schema change beyond the administrator role write, which is a data change, not a schema change.
Further Notes
Two of the facts this work is built on contradict the project's own documents. Both were verified against the running system rather than read, and both had been true when written. This is the ordinary cost of status living in prose; it is the reason status is supposed to live in the handoff and nowhere else. The correction belongs in the same session as the discovery.
The like-and-comment defect is the more interesting of the three, and worth remembering past this spec. The project already built a structural guard for exactly this bug class, after being bitten by it repeatedly. The guard did not catch this instance, because it asked "does this file read content?" and the offending routes do not read — they write. A guard is only as wide as its matcher, and a matcher written from the last incident describes the last incident. When widening it, the useful question is not "what did we miss?" but "what else can touch content?".
Rendering was chosen over validation for URL safety because validation only protects rows written after it exists. The general form of that preference — enforce at the point of use, not the point of entry, when untrusted data can predate the enforcement — is worth applying elsewhere rather than re-deriving.
Two credentials were exposed while investigating this work: a Google OAuth client secret and an SMTP connector password, both printed in full while listing the provider's connector configuration. Neither left the session, and neither is in a file this work touches. They are recorded here so that rotation is tracked rather than forgotten. The avoidable mistake was querying a configuration column without redacting it, when the column's contents were entirely predictable; this project has a rotation chapter in its history for closely related reasons.
The test account created by this walk is deliberate residue. It exists in two places — at the identity provider and as an AnswerHub profile — and it holds the first member number the provisioning path ever minted. Both records should be described precisely enough that deleting them later is unambiguous.
The administrator surface has never run. Everything known about it comes from tests using fixtures that grant the role directly. This walk is the first time the role is granted the way it will actually be granted, and the first time the moderation actions run against real rows. Treat surprises there as expected rather than alarming.