NOTES_SIAOHUB.md

SiaoHub — journal

What this is: the design/build journal for SiaoHub, a self-authored Next.js frontend that will become the only UI surface at git.siao.ai, with Forgejo demoted to a pure git-hosting + API backend. Distinct subject from NOTES_GIT_SERVER.md (which covers the Forgejo/host infra itself — OS, docker-compose, the sudo-less deploy pipeline) even though SiaoHub deploys onto that same infra — kept separate per the "split by subject" rule.

Status: built and running on the host. This header used to say "nothing has been built yet" and stayed that way through twelve chapters of building — worth noticing, because the same drift is what makes long journals untrustworthy.

Superseded since this journal was last chaptered: identity no longer comes from Forgejo OAuth (chapters 1–4 assume it does); clone approval is no longer a Forgejo collaborator (chapter 2's model); and git clone is now served by SiaoHub's own read-only proxy rather than by Forgejo. See NOTES_ACCOUNTS.md chapters 5–7 and SPEC_ACCOUNTS.md. The visibility-tier model and the single-seam rule are unchanged.


1. Design grilling session — what SiaoHub is and isn't

Expected before starting: didn't have a settled shape for "the git hosting frontend" beyond the HANDOFF.md note that a from-scratch visual identity was wanted instead of reusing the homepage's look, and that the Forgejo-template reskin path (NOTES_GIT_SERVER.md §5) was stalled waiting on that direction. Ran a structured grilling session (one decision at a time) to pin down the whole shape before any code.

What was decided, in the order it was resolved

  1. Tone: not design-forward like the homepage — tech/tool-forward, light/dark toggle, functionally GitHub-like. Explicitly "私人 GitHub."
  2. How GitHub-like: inspired, not pixel-cloned — owner's own analogy was "like Ryubing" (a fork with its own identity, not a reskin pretending to be the original, not wildly divergent either).
  3. Typography: monospace for code/data content (commit hashes, filenames, diffs), sans-serif for interface chrome. Deliberately not the homepage's EB Garamond — a clean split of identity between the two properties.
  4. Accent color: sage green (鼠尾草綠) — chosen specifically to avoid the "diff add-line green" connotation of a plain green, and to sit apart from GitHub's own blue.
  5. Dark mode depth: deep, layered gray — not pure black. Owner's own reference point ended up being a screenshot of Claude's own dark theme, "similar but slightly darker" — not a from-scratch guess.
  6. The big scope fork: reskin Forgejo's templates (cheap, bounded) vs. a full custom frontend with Forgejo as pure backend (expensive, open-ended). Owner picked full custom frontend specifically because Forgejo's default is a login/scaffold-first experience on the root path, and a public, browse-first landing page was non-negotiable. This is the single biggest scope decision in the whole project — everything downstream (Next.js, the OAuth bridge, the visibility-tier system) exists because of this one call.
  7. MVP scope inside that: repo list + file browser (tree, file view, rendered README) + commit history/diff. No issues/PRs — this is explicitly single-user right now, so collaboration features are the lowest priority, not just deferred busywork.
  8. Visibility model — this took multiple passes to actually pin down, worth recording precisely since it's the security-critical part:
    • First pass: "some projects need registration + review to clone."
    • Second pass, once "must be publicly browsable, not a login wall" was raised (owner showed a GitHub.com marketing-page screenshot as the counter-example): clarified that browsing stays public even for sensitive repos, only cloning is gated.
    • Third pass: sensitive repos actually get a degraded anonymous browse too — first 10 lines only, not full read access. General repos stay fully browsable anonymously.
    • Fourth pass: whether sensitive-repo clone approval is "fully blocked" or "gate-then-approve" — owner clarified it's approve-based (login + a manual application), not a hard wall, since a hard wall would lock the owner out of their own repo too.
    • Fifth pass, the one that reshaped the approval mechanism entirely: owner does not trust any web-reachable interface for this kind of privilege grant — "只有 SSH 跟 localhost 才有資格動,讓前端盜帳不存在" (only SSH/localhost gets to act on this, so a compromised frontend account can't do anything). This applies to both the visibility-tier rule mapping (topic → display rule) and the actual act of approving a clone request. There is deliberately no "approve" button in the web UI, not even for the owner's own logged-in session — the write path for this class of privilege change never touches the network-facing app at all.
    • Why this took five passes: each earlier framing ("review for clone," "registration," "manual approval") sounded finished but was actually still ambiguous about where the write happens. The real constraint only surfaced when the owner named the actual threat model (a compromised web session) rather than the desired UX. Read this chapter in full before assuming the visibility system is "just an if-statement on login state" — it's a trust-boundary design, not a feature toggle.
  9. Visibility tagging mechanism: Forgejo repo topics, using a visibility:<tier> naming convention rather than a single boolean tag — chosen specifically so new tiers can be added later (new topic name + a new rule in a small server-side config) without a schema migration or a new admin surface. Owner's stated requirement was "convenient to manage, extensible for future tiers" — topics satisfy "convenient" (managed where repos already live, in Forgejo itself); the naming convention satisfies "extensible."
  10. Auth: Forgejo's own OAuth2 provider, not a separate user store — justified as lower risk (no custom password/reset flow to get wrong) and a more natural fit (a Forgejo account already is the git identity). Google login is planned but deliberately deferred to being configured as an external source inside Forgejo itself, so SiaoHub's own code has zero awareness of which upstream identity providers exist.
  11. Stack: Next.js, TypeScript — owner's own call, stated reason was long-term maintainability and consistency with other, more complex projects already using TypeScript. Not argued into this; owner named it directly when asked to choose.
  12. Deployment: same host as Forgejo, folded into the existing git-siao-ai docker-compose + Cloudflare Tunnel pipeline (NOTES_GIT_SERVER.md §7–9) rather than a new mechanism — chosen so SiaoHub-to-Forgejo API calls stay on localhost.
  13. Naming: "SiaoHub" carried over from the earlier, abandoned reskin attempt — owner explicitly separated "the name" from "the visual identity," confirming the name was never actually rejected, only the reuse-the-homepage's-look plan was. Provisional, cheap to change, not worth revisiting unprompted.
  14. Testing: two layers, not one — unit tests (Vitest/Jest) aimed specifically at every branch of the visibility-resolution logic (the security-critical seam), plus Playwright end-to-end tests aimed at the actual user-visible paths, reusing the tool siao.ai already standardized on. Both layers were explicitly requested together ("都做"), not a pick-one.
  15. Test gating: four real stages (commit/push → unit; PR/review → full suite; internal/staging deploy → smoke test against the real deployed environment; production deploy → final gate) — and every stage is a real gate, not advisory. Owner's own framing: "沒必要騙 自己沒錯" (no point fooling yourself that it's fine). This also surfaced a concrete fix for the new repo: set enforce_admins: true from day one, unlike shihte/siao.ai's current enforce_admins: false, which let an admin-token push bypass the required check once already (see HANDOFF.md).

Status: spec written, nothing built

Full spec captured in SPEC_SIAOHUB.md (user stories, implementation decisions, testing decisions, out-of-scope list). No repo exists for SiaoHub yet, so the spec lives as a file rather than an issue-tracker entry — move it once a real tracker exists for this project. Next session should start from that spec, not re-derive the visibility model from scratch — chapter 8 above has the full reasoning trail if anything about the trust boundary seems arbitrary on a re-read.

2. Breaking the spec into tickets

Expected before starting: SPEC_SIAOHUB.md (chapter 1) was complete enough to slice into tracer-bullet tickets without further design discussion. Used /to-tickets; no tracker/ready-for-agent label is configured for this project (/setup-matt-pocock-skills hasn't been run), so tickets were published as local files under .scratch/siaohub/issues/ rather than to a real tracker — move them once SiaoHub gets a real repo/tracker.

What was found

Eight vertical slices, each independently demoable, in dependency order:

  1. Scaffold + deploy pipeline (unblocked — the actual starting point)
  2. Forgejo OAuth login (needs 1)
  3. Public repo list + the central resolveRepoVisibility seam, general-tier only (needs 1)
  4. File browser: tree + file view + README render (needs 3)
  5. Commit history + diff view (needs 3 — independent of 4, can run in parallel with it)
  6. Sensitive-repo preview tier, visibility:preview → first-10-lines (needs 3, 4)
  7. Clone gating, login-required (needs 2, 3)
  8. Manual clone-approval script, SSH/localhost-only (needs 6)

Files: 01-scaffold-deploy-pipeline.md through 08-manual-clone-approval-script.md in .scratch/siaohub/issues/.

A scope correction mid-review

Ticket 06 was first drafted with an implicit assumption that some kind of UI surface might exist for it (even just an indicator that a repo is sensitive). Owner corrected this immediately when reviewing the breakdown: "06 目前的規模不需要介面,安全第一" (06's current scope doesn't need an interface, security first) — this isn't a new decision, it's re-asserting chapter 1's SSH/localhost-only trust boundary against a ticket description that had started to drift from it. Ticket 06 was rewritten with an explicit "security-first scope note" telling whoever picks it up not to add any web-reachable control surface, and to stop and ask rather than build one if a requirement seems to call for it.

Why this is worth a chapter of its own, not just a diff note: this is the second time (after chapter 1's five-pass convergence) that the trust boundary needed re-asserting against a plausible-sounding but wrong default — the pull toward "surely there's a small admin toggle for this" is strong enough that it's worth flagging as a recurring failure mode for this project specifically, not a one-off mistake. Anyone touching visibility/clone logic later should treat "does this need a web UI" as a question with a pre-decided "no" answer, not an open one.

Status: 8 tickets published, ready to start at ticket 01

No tickets have been started. Ticket 01 is the frontier — everything else blocks on it. 03 and 04/05 are the next parallelizable pair once 01 lands.

3. Ticket 01 — scaffold + test/CI gate built; deploy half not reachable from here

Expected before starting: ticket 01 could be fully closed out in one session. Turned out to split cleanly into two halves with very different verifiability — everything in the ~/siao-ai/siaohub repo itself could be built and actually run; everything touching the live host could not, because this session has no SSH access to it.

What was built and verified for real (not assumed)

What's explicitly NOT done, and why

Status: partially done — code/tests/CI-config complete and verified; deploy, remote, and branch-protection still open

Next session picking this up should NOT re-scaffold or re-verify the app — read this chapter, then go straight to: (1) decide GitHub vs. Forgejo for the siaohub remote, (2) get it pushed to the live host alongside the updated git-siao-ai compose/cloudflared files, (3) set enforce_admins: true once a remote exists, (4) set STAGING_URL once something is actually deployed. None of that needs more design — it needs the access this session didn't have.

4. Tickets 02–08 built in one continuous session — all 8 tickets now have real code

Expected before starting: this session picked up right after ticket 01 with a stated goal of finishing all 8 tickets, not stopping to check in between. Turned out to be achievable entirely without a live Forgejo or SSH access, by building a small fixture Forgejo API server (e2e/fixtures/fake-forgejo-server.mjs) once and testing every subsequent ticket against it — the same pattern used throughout, not reinvented per ticket.

What was built, per ticket (all committed, all typecheck+test green at each step)

A real bug caught by testing, not by reading

Two login.spec.ts tests failed on first run with 500s. The cause wasn't the new code — it was playwright.config.ts's reuseExistingServer: !process.env.CI picking up a dev server that had been started earlier (via preview_start, for the owner to look at manually) without the OAuth/session env vars the tests needed. Fixed by setting reuseExistingServer: false unconditionally, so a test run never silently inherits state from a manually-started server. Worth remembering if e2e tests ever show unexplained 500s again on this project: check for a leftover dev server before assuming the code broke.

A real security gap caught by self-review, not by a test

After ticket 08, a self-review pass (not a test — this class of bug doesn't show up in a passing suite) found both auth cookies (oauth_state, the session cookie) missing secure. Fixed by gating on NODE_ENV === "production". Flagging this because it's exactly the kind of gap that's invisible in local http://localhost testing and only bites once the real HTTPS deploy exists — worth a deliberate look, not just trusting "the tests pass."

What's still NOT done (same shape as ticket 01's gap, now for the whole app)

Nothing beyond ticket 01's original list changed here: no remote chosen for siaohub, nothing pushed to the live host, no enforce_admins, no STAGING_URL. All 8 tickets' code exists and is tested against a fixture Forgejo; none of it has been proven against the real, self-hosted Forgejo instance described in NOTES_GIT_SERVER.md — that proof only happens once there's SSH access and an actual deploy. Don't read "all 8 tickets built" as "SiaoHub is live" — those are different claims.

Status: all 8 tickets have real, tested code; live deployment still blocked on server access

Next session: same next-steps as ticket 01 (chapter 3) — pick a remote, push, deploy, then re-verify against the real Forgejo instance rather than the fixture. The fixture server was a stand-in for testing, not a substitute for that final real-integration check.

5. Applying the visual identity (light/dark theme, fonts, accent) after the owner looked at it

Expected before starting: the owner would look at the running scaffold and want to see the actual design from the earlier /grill-me session (sage green, #FAFAFA, monospace-for-code) applied, not bare HTML — this wasn't in any of the 8 tickets, all of which were functional-only.

What was done

Status: done, verified in-browser (light + dark), committed

Not tracked as a numbered ticket — done directly at the owner's request after looking at ticket 01–08's output. See git log "Apply the visual identity..." for the full commit.

6. UX grilling session — the 8 tickets are functional-only, UX was never in scope

Expected before starting: the owner, after confirming basic functionality works, immediately flagged that overall UX is "非常不足" (very lacking) — correct: none of tickets 01–08 or the visual-identity pass (ch.5) touched information architecture, error states, or navigation depth. This chapter is a second grilling session, structured the same way as the original design grill, scoping a new batch of work distinct from the original 8 tickets.

Decisions reached so far (mid-session — more below once resumed)

  1. Sequencing: foundation first (breadcrumbs, real folder-tree navigation, loading/error/empty states, pagination) → visual polish (cards, buttons, hover, icon system) → responsive/mobile last. Explicitly "全要" (all of it) — this is a priority order across multiple batches, not a scope cut. Owner's reasoning implied: mobile browsing of code is a rare case for what's currently a private, single-user tool, so it's fine last; foundation gaps actively block using the thing at all once repo/file/commit counts grow.
  2. Folder tree navigation: rejected the originally-recommended flat-list-stays-flat approach. Owner wants exact GitHub parity — "完全複製 github" (completely copy GitHub) is now a standing instruction for this batch, not just this one decision. Concretely: clicking a folder navigates to a new server-rendered page (not client-side expand/collapse), with breadcrumbs.
  3. Branch/ref switcher: also in scope now, despite an initial recommendation to skip it (no multi-branch repo exists yet to validate against). Owner's override: "沒理由不做" (no reason not to) — read together with decision 2, the standing instruction is "match GitHub's real behavior," not "match GitHub only where there's already a concrete use case." Don't re-propose deferring GitHub-parity items for lack of a test scenario in this batch — that argument has already been explicitly rejected once.
  4. URL scheme: adopted GitHub's exact structure to carry the branch: /[owner]/[repo]/tree/[branch]/[...path] (folders), /[owner]/[repo]/blob/[branch]/[...path] (files), /[owner]/[repo]/commits/[branch] (commit list). This supersedes the ticket-04/05 routes (/blob/[...path], /commits with no branch) — those will need to be replaced, not extended, when this batch is built. Branch list and default branch come from Forgejo's API (GET .../branches, repo's default_branch field) — nothing hardcoded to main anymore.
  5. Error/empty states: custom 404 (repo/path/branch-not-found as distinct messages, in SiaoHub's own visual style, not Next.js's default), plus empty-state copy for repos/commit-lists with nothing in them.

The ambiguous "1" got re-asked and resolved

Re-asked cleanly as A (keep current full-page-render model) vs B (real skeleton loaders). Owner picked B, explicitly "比照 GitHub" — so the GitHub-parity instruction (decision 2/3 above) extends to loading state too: new tree/blob/commits pages get loading.tsx/Suspense boundaries, not just a bare server-render-then-show.

Remaining decisions from the rest of the session

  1. A scope boundary on "完全複製 github," reached after I mis-read it once: I initially read "完全複製" as "copy everything including color," which would have contradicted the original design session (sage green specifically chosen to avoid GitHub-blue, see ch.1 §4). Owner corrected this: the copying is of operating habits / layout / icons — muscle-memory transfer, since this is a personal project the owner will use themselves — not color identity, which stays SiaoHub's own, and not feature scope, which stays deliberately simpler than real GitHub (single uploader, personal portfolio, no issues/PRs — consistent with the MVP's original scope decision). Read "completely copy GitHub" in this project as scoped to interaction/layout/iconography only, never as license to also copy colors or add collaboration features — both of those were explicitly and separately decided against.
  2. Icons: @primer/octicons-react — GitHub's own MIT-licensed, openly published icon set. Not a copy/recreation; the real package. Colors get themed via CSS to match SiaoHub's palette, not left as GitHub's own icon colors.
  3. Repo list metadata: icon + name + description + language dot + last-updated time + ⭐/👀 counts. Star/watch counts stay visible (pulled from Forgejo's real numbers) — my initial recommendation to drop them (reasoning: meaningless for a single-user tool) was overridden. Owner's distinction: keep the display, skip the interactive feature (no click-to-star build this round) — "單純簡化 功能" (simplify functionality only), not "simplify what's shown."
  4. Per-file last-commit info (the classic GitHub file-list detail: each row shows the commit that last touched it + relative time). Confirmed technically feasible without a bad N+1: Forgejo/Gitea's API has no batch "last commit per directory entry" endpoint, but does have GET .../commits?path=<path>&limit=1&sha=<branch> for a single path. Because folder browsing is already scoped to one directory level per page (decision 2), this bounds the queries to "however many entries are visible in the current directory," fired in parallel, on-demand when that directory is actually opened — not a whole-repo precompute. Owner confirmed this shape explicitly ("頁面訪問時才查詢").

Status: grilling complete, full batch decided — ready for /to-tickets

Every open question from this chapter got resolved before moving to ticket-writing (§7 below). Nothing here is provisional or needs re-confirming — treat this chapter as settled the same way ch.1/ch.2 are for the MVP batch.

7. UX batch broken into tickets 09–16, execution begins

Expected before starting: after /to-tickets, execution would look like tickets 01–08 — build, test, commit, repeat. Applied fan-out-and-prune: 09 (branch-aware routing) is a single large chain almost everything else blocks on, built directly; 16 (repo list metadata) is fully independent and was queued for a parallel worktree- isolated agent — except worktree isolation turned out to be unavailable in this environment (the parent folder isn't a git repo, only siaohub nested inside it is), so 16 was deferred to run sequentially right after 09 instead, to avoid two processes editing one shared working tree with no isolation.

Ticket 09 done — and a real, previously-invisible bug found along the way

Full ticket in .scratch/siaohub/issues/09-branch-aware-routing.md. Built: /tree/[branch]/[[...path]], /blob/[branch]/[...path], /commits/[branch] replacing the fixed-main routes from 04/05; /[owner]/[repo] redirects to the repo's real default_branch; a BranchSwitcher client component; Forgejo client gained listBranches and now requires an explicit ref everywhere instead of defaulting to "main". Fixture server extended with a second branch (dev) carrying different content, so branch-switching is actually exercised, not just plumbed through.

The bug: the new branch-switcher e2e test hung for 30s waiting on client-side navigation that never happened. Root cause, found by checking document.querySelector(...).__reactProps* directly in a debug test: the <select> element was never hydrated by React at alluseRouter's onChange handler was never attached. This wasn't a React/Next bug in the component; it was next.config.ts missing allowedDevOrigins. Next dev's default cross-origin protection blocks requests to dev resources from origins it doesn't recognize, and it turns out that includes the client hydration bootstrap itself, not just the HMR websocket — and both Playwright's baseURL and Claude's browser-preview tool reach the dev server via 127.0.0.1, not localhost, which Next didn't have allow-listed. Fixed by adding allowedDevOrigins: ["127.0.0.1", "localhost"].

Why this went undetected through tickets 01–08 and the visual-identity pass: manual browsing in this session happened to go through http://localhost:3000 (allowed), so ThemeToggle visibly worked when looked at by hand. But every e2e test up through ticket 09 tested against 127.0.0.1 and happened to never depend on client-side JS actually running — logged-in/out state comes from server-rendered cookies read on the server, not client interactivity; the two existing client components (ThemeToggle, and login/logout, which are plain <a> tags) either don't need hydration or fail invisibly (a never-rendering toggle button just looks like "the OS theme is already right"). Ticket 09's branch switcher was the first thing in this project that actually required working client-side JS to pass a test, and it immediately surfaced a bug that had been silently present since ticket 01's scaffold.

Lesson for future e2e work here: a passing Playwright suite through ticket 08 was not evidence that client-side interactivity worked — only that server-rendered content and things reachable by an accessible <a href> worked. If a future ticket adds another interactive client component, don't assume "the pattern already proven by ThemeToggle" covers it; ThemeToggle was never actually proven by a test, only by eye, on the one origin that happened to work.

Also hit, unrelated to the origin bug: a stale Turbopack .next build cache kept serving an already-fixed broken import path across fresh npm run dev restarts, briefly looking like the fix hadn't taken. rm -rf .next resolved it. Worth checking for first if a fix that looks obviously correct doesn't take effect.

17/17 e2e, 15/15 unit, typecheck clean, committed. Full detail in the commit message ("Ticket 09: branch-aware URL scheme + branch switcher").

Ticket 16 done — sequentially, not in parallel as planned

Built right after 09 (worktree isolation wasn't available, see above), but otherwise as scoped: language color dot, "updated X ago" (src/lib/relativeTime.ts, small TDD'd util, no library), and ⭐/👀 counts on each home-page repo row, all from real Forgejo repo-object fields (language, updated_at, stars_count, watchers_count) — not invented data. Star/watch stay strictly display-only, and the e2e test asserts the absence of any star/watch button, not just that the numbers render, per the owner's explicit "顯示但不要互動" distinction from the grilling session.

19/19 unit, 18/18 e2e, typecheck clean, committed.

Status: all 8 UX tickets (09–16) done — full batch complete

Continued past the original 2-ticket checkpoint at the owner's "先一起完成" (finish them all together) instruction. Built in dependency order: 10 (per-directory browsing + breadcrumbs), 11 (per-file last- commit), 12 (numbered pagination), 13 (custom error/empty states), 14 (skeleton loading — commits only, see below), 15 (Octicons). Each followed the same discipline as the MVP batch: typecheck → unit → e2e → commit, no skipped verification.

Two real Next.js App Router discoveries during 10–15, both costly to find, both worth remembering for any future work on this project

1. A layout's notFound() cannot be caught by that same segment's own not-found.tsx (ticket 13). The layout is what renders the not-found.tsx boundary, so if the layout itself throws, its own not-found.tsx is unreachable — the error bubbles to the PARENT segment's not-found.tsx instead. Symptom was confusing: HTTP status was correctly 404 the whole time, only the rendered content was wrong (fell through to the generic root not-found page), with no error logged anywhere. Fixed by moving each not-found.tsx one folder level up from the layout.tsx whose check it's meant to catch. Page-level notFound() calls don't have this restriction — only layout-level ones do.

2. loading.tsx (Suspense streaming) and a page's own notFound() call are fundamentally in tension (ticket 14). Adding loading.tsx opts a route into streaming: Next sends the initial response shell at status 200 before the segment's async work resolves, then swaps in real content — so if that segment's page later calls notFound() after a data fetch, the 200 status is already on the wire and can't be downgraded to 404, even though the rendered content is correct not-found copy. Caught by two existing 404-status assertions flipping to 200 the moment loading.tsx was added. No clean fix found for routes where the page itself must notFound() post-fetch (tree, blob) — moving the check into a layout doesn't work either, because that layout's not-found.tsx boundary (per discovery #1, one level up) would collide with an existing sibling boundary and undo ticket 13's distinct-message work. Resolved by keeping loading.tsx only on commits (whose page never calls notFound() itself) and deliberately dropping it from tree/blob — correctness of the already-shipped 404 contract won over the loading skeleton. Documented in ticket 14's commit rather than silently reduced.

If either of these need revisiting: don't re-attempt "just add loading.tsx everywhere" or "just add a not-found.tsx next to the layout" without reading the two entries above first — both look like the obvious first attempt and both were tried and found broken by an actual failing test, not by inspection.

What's still NOT deployed

Same gap as ticket 01/chapter 4: everything above is tested against the fixture Forgejo server and committed locally. Nothing has been pushed to the live host, no remote chosen for siaohub, no enforce_admins, no STAGING_URL, and none of it has been proven against the real, self-hosted Forgejo instance. That remains the actual next step, not more feature work — see chapter 3/4 for the specifics.

8. Local Docker deployment test — the actual production image, verified for the first time

Expected before starting: the Dockerfile had been written in ticket 01 but never build-verified (Docker daemon wasn't running in that session — flagged explicitly in NOTES_SIAOHUB.md ch.3/HANDOFF.md). This session had a working Docker Desktop, so: launched it (open -a Docker), waited for the daemon, and actually ran the deploy path end to end — not the dev server, the real multi-stage production build.

What was verified, for real, not assumed

What this does NOT prove

This is still the fixture Forgejo API, not the real self-hosted instance — the container-to-Forgejo network path ([redacted: internal host] here vs. the real deploy's http://forgejo:3000 over the docker-compose network) is structurally similar but not identical, and nothing about Cloudflare Tunnel, the real git-siao-ai compose stack, or the actual server's Docker/OS environment was touched. This closes the "does the Dockerfile even build and run" gap from ticket 01 — it does not close the "deployed to the live host" gap, which is unchanged and still needs SSH access.

Status: production Docker image build+run verified locally; live-host deploy still not attempted

docker images shows siaohub:test, 316MB, built and torn down cleanly in this session (container stopped, image left for reference — not pushed anywhere). Next session with server access should still expect to run this same docker build fresh on the actual host as part of deploying (this local image isn't portable to the server), but now knows the Dockerfile itself is sound going in.

9. UI/UX Optimization to Match GitHub Layout & Diffs

We implemented a visual redesign to bring SiaoHub's interface in line with GitHub's layouts, typography, and interactive components.

What was done

Status: completed, all 29 E2E tests passing, UI fully optimized

10. Phase 2 Homepage Redesign & Interactive SiaoHub Functional Integration

Expected before starting: SiaoHub UI had static/decorative placeholders (such as static Watch/Star buttons, mock Contributors/Releases lists, and inactive "Go to File" / "Download ZIP" hooks). The homepage was also simple. We expected to transform these elements into functional, API-backed features integrated with Forgejo.

What was done

Status: completed, all UI elements functional, 100% test pass rate, deployed on real server over Tailscale for internal testing

11. Supporting Slash Branches, Dynamic Languages, Configurable Pagination, and Binary Safety

Status: completed, all E2E tests passing, public access mode restored, Gitea cleaned of siaohub repository, optimized caching and binary safety, deployed on real server over Tailscale

12. Supporting Special Branches (with Slashes) and LICENSE Metadata Details

Expected before starting: User reported persistent "Branch not found" issues when navigating folders or switching branches (such as docs/system), and flagged that LICENSE was not done.

What was done

Status: completed, slash-branches fully supported, dynamic LICENSE details card built and verified, clean rebuilt and deployed on tailscale server


13. Making documentation readable, before making it public

The owner reported one symptom — "the document links still don't go anywhere" — and it turned out to be two bugs stacked, plus a third the investigation walked into.

The links were never rewritten. renderMarkdown emitted href exactly as written, so [guide](docs/getting-started.md) reached the browser as a relative URL and was resolved against the reader's current URL. From /siao/RenUniversal/tree/main that produces /siao/RenUniversal/tree/docs/getting-started.mdmain is consumed as if it were a path segment. Every link in a documentation hub was dead, and dead in a way that looked like a routing bug rather than a rendering one.

A .md file was source, not a document. The blob page ran every file through highlightFile, so even a correct link landed on syntax-highlighted markdown. For a repository whose README is a table of contents pointing at fifteen files, that is the difference between a documentation site and a directory listing.

Fixed together because they have to be: rendering documents means their internal links (../rule-engine.md from docs/internals/) need the same rewriting, keyed on the directory the document itself lives in. Anchors needed one more thing — marked stopped emitting heading ids in v9, so [jump](#section) had nothing to land on; ids are generated in a renderer override and allow-listed for headings only.

What the raw route is not allowed to be

Relative images need bytes, which means an endpoint that serves file content. The obvious version of that — serve the file, set the content type from its extension — would have been the worst thing in this codebase. This origin issues clone tokens from /api/clone-token, and RenUniversal contains docs/DETAILED_ARCHITECTURE.html. A raw endpoint honouring that file's own type means committing a file is enough to run script on git.siao.ai.

So the route serves an allow-list of raster types, takes the content type from the list rather than the file, sets nosniff, and 404s everything else — SVG included, which is a script container with an image's extension. The Rendered/Source toggle deliberately opens no URL at all: it is client state over two strings the page already produced.

The sixth bug, and the seventh that nearly replaced it

The tree page rendered a visibility:preview repo's entire README — known, recorded, unfixed. It is fixed here because this work touched the same code, and because rendering markdown in the blob view is exactly the shape of change that produces the next one: render first, trim the HTML after, and the truncation becomes cosmetic. Both pages now truncate the source before any renderer sees it.

The test for it was checked against the unfixed code before being believed — with the truncation removed, CLASSIFIED 18/19/20 appear in the tree page's HTML while the page still displays "Showing the first 10 lines only". A test that passes against both versions would have been worse than no test.

Status: built, deployed, verified from the public internet. Suites at the time of writing: typecheck clean, 126 unit pass / 4 skip, 55 e2e pass.

14. AEO / SEO / GEO — describing the site to machines

Owner: "對SiaoHub做AEO/SEO/GEO". Run as a /grill-me interview first, because the same tension as the siao.ai work applies here and resolves differently: a repository browser has plenty of crawlable text, and the reason not to expose it is not editorial restraint but hardware. Every page is a live call to Forgejo, and the space a crawler would walk is (branches × files × commits).

The owner chose all three goals rather than one — correctness, per-repo discoverability, and answering AI retrieval — and then narrowed the crawl surface themselves: "可以爬到根目錄、專案名稱、MD檔案". That answer is what makes the three compatible. Markdown is the part of a repository written to be read; the file-by-file walk is the part that costs money and answers nothing.

A dependency the request did not contain. Opening .md in robots.txt achieves nothing on its own: deep directory listings stay closed, so no crawlable page links to a document in a subdirectory. The permission and the discovery path are one decision, not two. The sitemap names the documents instead — which also lets it name only the default branch's copies, something robots.txt cannot express, since a branch name is just a path segment to it.

Two structural changes went first, both because the obvious approach was wrong.

publicOrigin() looked like the answer to "what is this site's address" and is not. It is derived from OIDC_REDIRECT_URI, which on this host is https://[redacted: internal host]:8443/auth/callback — measured on the server, not inferred — because that is the only host an OIDC round trip can complete on. That is why /login 404s publicly, and it is correct for auth. A <link rel="canonical"> built on it would have declared the site's official address to be a hostname the internet cannot resolve, which is worse than emitting none, because search engines act on it. Hence SITE_URL, one variable with one meaning.

/{owner}/{repo} was an eighteen-line redirect() to /tree/{defaultBranch}. Left alone, the canonical URL would have been either a 308 or a URL naming a branch — the second breaks when a default branch is renamed. It renders the repo directly now, and both routes share one component.

Three things found by building, not by reasoning

A content-disclosure bug, introduced and caught in the same session. The preview-card route is a route of its own: it never passes the [repo]/layout.tsx boundary that 404s a private repo, and the Forgejo client it calls carries the backend token, which can see private repos. It rendered a repo's description and language into a PNG for anyone who asked, without consulting the seam.

Worth stating precisely rather than dramatically: on this host it would have leaked nothing, because FORGEJO_BACKEND_TOKEN is not set — checked on the server, not assumed — so Forgejo answers unauthenticated calls without private repos. It would have become a live leak the day that token is minted, which is a decision already sitting open for star/watch. A bug that is safe only by virtue of an unset variable is not a safe bug; that is the exact arrangement visibility.ts was written to stop relying on. seam-coverage.test.ts failed the moment the file appeared — which is exactly what that test was written for, and the first time it has caught something that was not already known. The decision now lives in a pure function with its own tests; a private repo's card is identical to one for a repo that does not exist, because the owner and name came from the requester's own URL and disclose nothing.

The first attempt to test this asserted byte-equality of the two PNGs. That premise was wrong — two cards differ by repo name whether or not either leaked — and the test was moved to the seam where the claim is actually checkable rather than weakened until it passed.

A root opengraph-image.tsx renders every 404 page blank. Correct status, empty body. Found because error-states.spec.ts went from ten passes in six seconds to three in fifty-nine, and bisected to that one file; the repo-level card has no such problem, because no not-found page sits under it. The site card is an ordinary route instead. Worth recording as a Next behaviour rather than a mystery: the file convention attaches itself to app/not-found.tsx too.

Page-level openGraph replaces the layout's object rather than merging with it, so every page that described itself silently lost og:site_name. Caught by an e2e assertion, not by reading documentation.

Cache, and where it belongs

sitemap.xml and llms.txt were prerendered at build time on the first production build — when Forgejo is unreachable and SITE_URL is not the runtime value. That bakes an empty file built from the wrong origin and serves it until its first revalidation. Both routes are now explicitly per-request, with the expensive part cached for a day in the data layer. The rule that fell out: the caching that protects Forgejo belongs to the data, so the route can be honest about running at request time.

Page-level caching was considered and rejected outright — pages here vary by session, and an ISR cache would serve one visitor's view to another. That is a correctness question, not a performance trade.

Decisions recorded because they are not obvious

AI crawlers are allowed by omission, not by invitation. The owner chose allow-all, consistent with siao.ai. The implementation deliberately names no crawler: a named User-agent group replaces the * group rather than adding to it, so User-agent: GPTBot / Allow: / would hand exactly one crawler the file-by-file walk everything else prevents.

llms.txt is built and is honestly optional. No major model provider has committed to reading it. It shares the sitemap's data, costs nothing if unread, and is not load-bearing — the rendered pages, the sitemap and the structured data are.

A preview-tier repo is named but contributes no documents, and its file pages are noindex. robots.txt opens .md, so its truncated files are fetchable; noindex is the only instruction that survives being read, since a merely-disallowed URL can still be indexed from an external link.

The Person is referenced, not restated. siao.ai's JSON-LD has no @id, and giving it one is a change to another repository, so the shared url carries the link. The privacy rule from that work — silhouette traits yes, exact age and legal name no — is enforced here by a negative assertion rather than by intention.

7.3MB of fonts are committed. ImageResponse has no usable built-in font in this setup — called without one it throws and the card 500s — so a Latin face is always loaded and a full CJK face only for text that contains CJK. The CJK face is not subsetted on purpose: subsetting trades a guaranteed correct render for an occasional broken one, in exactly the cases most likely to matter.

Deploying it, with the push chain still broken

Forgejo still takes no pushes, which is what had blocked this work's predecessor. The way round it was a git bundle over SSH: bundle the branch locally, copy it to the host, git fetch it into ~/siaohub, check it out, rebuild the one service. That keeps real history and the real commit hash, so pushing the same commit later reconciles instead of colliding — which rsync of a working tree would not.

Two findings fell out of doing it. deploy.sh would fail today: it checks out from $HOME/forgejo-data/git/repositories/siao/infra.git, a path that does not exist — the bare repos are under ~/forgejo/data/git/repositories/siao/. And those bare repos are reachable over ordinary siao@ SSH, which is the only route found that needs no Forgejo credential. Recorded, not used: it writes straight into Forgejo's live runtime volume.

Search Console: the step that turned out not to exist

The interview treated "verify git.siao.ai" as real work, possibly needing a meta tag shipped and deployed. It was not. The account already had a Domain property (sc-domain:siao.ai), DNS-verified, and a domain property covers every subdomain — so ownership of git.siao.ai was never in question. Checking what verification method the existing property used, before planning work around it, would have found this earlier; the TXT record was visible from a dig all along.

The other lesson is about picking the tool that answers the question. The Rich Results Test said "no items detected" on both pages, which reads like failure and is not: it only reports types that produce rich results, and WebSite / SoftwareSourceCode / Person produce none. NOTES_HOMEPAGE.md ch.21 walked into the same reading. The Schema Markup Validator is the tool with the answer, and it returned zero errors and zero warnings on both pages, with the home page's aboutPersonhttps://siao.ai/ resolved — the disambiguation link this whole chapter exists for, confirmed by something other than the author.

Sitemap submission reported 21 discovered pages for git.siao.ai/sitemap.xml, matching the file exactly.

Status: live on git.siao.ai, verified from the internet — 21 URLs in the sitemap, canonical naming the public origin, and the whole pre-existing security posture re-checked unchanged (Forgejo's own UI paths still 404, receive-pack still 403, clone-token still 401). Suites at the time of writing: typecheck clean, 167 unit pass / 4 skip, 86 e2e pass, and 62 e2e on siao-ai.

15. Full-check fixes: archive-route traversal and clone-token revocation

Two audit findings (2026-08-17), fixed and deployed (44659c4).

Archive-route path traversal → backend-token confused deputy. The api/v1/repos/[owner]/[repo]/archive/[filename] route interpolated filename into the internal Forgejo URL unencoded, and filename is not part of the loadBrowsableRepo auth check. Verified against the installed Next 16.2.12: route-handler segments decode %2f, so ..%2f..%2f..%2f<other>%2f<repo>%2f… plus fetch/URL's .. normalization steers the backend token to any repo's archive (private, preview) or any GET path — the one place the codebase hand-built a Forgejo URL instead of going through forgejoClient() (which encodeURIComponents every segment). Fixed: reject non-single-segment filenames + encode. Same unencoded-interpolation on getCommitDiff's sha (anonymous canBrowse path) — encoded too.

Reachability, because it changes the severity and is easy to misjudge: it needs canClone, which needs a clone-approvals.json entry — currently {"approvals": []}, so nobody can reach it today. It arms the first time the owner grants one approval. And note the second gate is already gone: FORGEJO_BACKEND_TOKEN was empty until 2026-08-14 (SiaoHub read Forgejo anonymously); it's now set to read:repository (NOTES_GIT_SERVER ch.11), so once an approval exists this reads any repo that token sees. It stays latent, not live — but "latent" here means "one clone-approval grant away", not "needs new code".

Clone tokens ignored revocation. verifyCloneToken checked signature + aud

Status: solved and deployed. Archive traversal latent-until-first-approval; clone revocation live.