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
- Tone: not design-forward like the homepage — tech/tool-forward, light/dark toggle, functionally GitHub-like. Explicitly "私人 GitHub."
- 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).
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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." - 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.
- 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.
- Deployment: same host as Forgejo, folded into the existing
git-siao-aidocker-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. - 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.
- 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.aialready standardized on. Both layers were explicitly requested together ("都做"), not a pick-one. - 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: truefrom day one, unlikeshihte/siao.ai's currentenforce_admins: false, which let an admin-token push bypass the required check once already (seeHANDOFF.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:
- Scaffold + deploy pipeline (unblocked — the actual starting point)
- Forgejo OAuth login (needs 1)
- Public repo list + the central
resolveRepoVisibilityseam, general-tier only (needs 1) - File browser: tree + file view + README render (needs 3)
- Commit history + diff view (needs 3 — independent of 4, can run in parallel with it)
- Sensitive-repo preview tier,
visibility:preview→ first-10-lines (needs 3, 4) - Clone gating, login-required (needs 2, 3)
- 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)
siaohub/: Next.js 16 + TypeScript scaffold,src/lib/health.tswritten test-first (health.test.tsred → green), exposed at/api/healthnpx tsc --noEmitclean,vitest rungreen (1 test),playwright testgreen (2 tests: home page renders without a login wall, health endpoint responds) — all actually executed, not inferred from the code reading right.github/workflows/ci.yml: unit tests on every push, full suite (unit+e2e) on PR, plusstaging-smokeandproduction-gatejobs — these last two are wired to avars.STAGING_URLguard and deliberately do nothing yet rather than fake-pass, since no staging target exists to hitDockerfile(multi-stage, Next.js standalone output) — written but not build-verified: Docker daemon wasn't running in this environment. Don't treat "the Dockerfile looks right" as "the Dockerfile builds" until someone actually runsdocker buildon itgit-siao-ai/docker-compose.yml: added asiaohubservice (build: ../siaohub, internal-only port 3001, talks toforgejoover the compose network athttp://forgejo:3000)git-siao-ai/cloudflared/config.yml: ingress forgit.siao.airepointed from127.0.0.1:3000(Forgejo directly) to127.0.0.1:3001(siaohub) — the actual mechanism that makes Forgejo stop being publicly reachable, per the whole point of ticket 01/the spec
What's explicitly NOT done, and why
- Nothing pushed to the live host. Both
siaohuband thegit-siao-aicompose/cloudflared changes are committed locally only. This session has no SSH access to the server described inNOTES_GIT_SERVER.md, and pushing to the infra repo's auto-deploy pipeline is exactly the kind of action that shouldn't happen without the owner present — same caution as the tunnel-restart rule already inHANDOFF.md. - The compose
build: ../siaohubcontext won't resolve on the host yet — it assumessiaohubexists as a sibling checkout next togit-siao-ai, which is true on this machine now but not true on the server until it's actually cloned/pushed there. Don't push the compose change alone and expect it to deploy cleanly;siaohubneeds to exist on the host first (or the compose file needs to pull a built image instead of building from a path — not decided, flagging it here rather than picking one silently). - No GitHub (or Forgejo) repo created for
siaohubyet — same unresolved-remote status asapps-siao-ai. Branch protection /enforce_admins: truecan't be configured until a remote exists to configure it on. Ticket 01's acceptance criterion on this is still open. STAGING_URLisn't set anywhere — the staging-smoke and production-gate CI jobs are real code, not stubs, but they're inert until there's an actual staging deployment to point them at.
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)
- 02 (OAuth login):
/login→ Forgejo authorize URL,/auth/callbackexchanges code → session (signed JWT viajose, httpOnly cookie),/logoutclears it. No custom password store anywhere — this also producedsrc/lib/forgejo/client.ts, the one seam every later ticket calls through for Forgejo data. - 03 (repo list + visibility seam): home page lists repos from the
client;
src/lib/visibility.ts'sresolveRepoVisibility(repo, session)— the single decision point every page uses. - 04 (file browser): repo page (tree + rendered README via
marked) and a blob page (syntax highlighting viahighlight.js, extension-based language detection). - 05 (commits/diff): commit list + per-commit diff pages, independent of 04 as planned.
- 06 (preview tier): this is where a real gap in ticket sequencing
showed up — the preview-tier branch of
resolveRepoVisibilityand the blob page's truncation logic were written during 03/04 (cheapest to shape the function once), but deliberately left untested until this ticket, matching its actual acceptance criteria. Worth remembering: writing a seam's full shape early and deferring its test coverage to the ticket that owns it is fine; skipping the tests entirely because "the code's already there" would not have been. - 07 (clone gating): same pattern — the gating check existed since 04, this ticket is where the three states (anon/general, logged-in/general, logged-in-unapproved/preview) got proven end to end.
- 08 (manual approval script):
scripts/approve-clone-access.mjs, deliberately outsidesrc/appso nothing in the web app can reach it. Confirmed by grep, not just by construction. Verified once, manually, end to end against the fixture server (extended with aPUT .../collaborators/:usernamehandler) — no Playwright coverage for this one, matching the ticket's own "low-frequency, not e2e-tested" call.
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
globals.css: CSS custom properties for light/dark, sage green accent, monospace for code/paths/hashes vs sans-serif for chrome — matches the/grill-medecisions from the design session verbatim.ThemeToggle.tsx: client component, localStorage-persisted, defaults to OS preference (prefers-color-scheme) on first visit.- Login/logout moved from the home page into a shared header so it's
consistent across every route, not just
/. - highlight.js token colors wired to the same CSS variables so syntax highlighting theme-switches with everything else — this was initially missed (code rendered in the right font but with zero syntax color) and caught by actually looking at a screenshot, not by the test suite, which had no assertion on color.
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)
- 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.
- 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.
- 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.
- 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],/commitswith 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'sdefault_branchfield) — nothing hardcoded tomainanymore. - 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
- 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.
- 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. - 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."
- 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
all — useRouter'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
docker build .— succeeds cleanly:next buildcompiles, typechecks, generates all 10 routes (/,/[owner]/[repo], tree/blob/commits,/api/health, auth routes), standalone output copies correctly into the run stage. 316MB image, ~6s build.docker runwith the production image, pointed at the same fixture Forgejo server (via[redacted: internal host], since the container can't reach127.0.0.1on the host directly) — confirmed working through the actual container, notnext dev:/api/health→200 {"status":"ok"}/→ full repo list, correctly rendered (language dots, relative times, star/watch counts all present) — production build output matches what dev mode showed all along, not just "looks similar"/siao/demo→307redirect to/siao/demo/tree/main→200, followed correctly by curl/siao/does-not-exist→ real404, confirming the notFound()/ layout boundary work from ticket 13 survives a production build, not just Turbopack dev mode (production uses a different bundler path — this was a real, distinct thing to confirm, not redundant with the dev-mode e2e suite)- Container logs: clean,
✓ Ready in 0ms, no runtime errors
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
- Client-Side Repository Header (
RepoTabs.tsx): Created a tab bar navigation with "Code" and "Commits" icons, breadcrumbs, and visibility badges. Addedaria-labelto ensure exact accessible name matching without browser-injected spaces. - Two-Column Homepage Layout: Implemented a flex grid layout (
.gh-grid) on the repository tree root view containing:- Left column: Branch switcher, latest commit metadata strip, folder/file listing table, and README markdown section.
- Right column: Sidebar showing repo Description, Topics tags, Watchers/Stars stats, and Languages color percentage bar.
- Latest Commit Bar: Rendered a summary strip above the file tree showing the latest commit author, message, relative timestamp, and short SHA link.
- Commits Grouping: Organized commits chronologically by day.
- Visual Diff Viewer: Added a diff parser to segment unified git diff data by file, showing added lines in green, deleted lines in red, hunk headers in blue, and line numbers in tabular alignment.
- E2E Test Alignment: Fixed list tag structure (
ul/li) and trailing slashes (/) for directory nodes to satisfy all 29 Playwright E2E tests, which now pass successfully.
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
- Two-Column Dashboard Homepage Redesign:
- Upgraded SiaoHub's main landing page (
/) to a premium dashboard layout. - Added a Hero Banner with a sleek navy-green gradient, radial glow accent, and visual stats indicators (Repositories count, Stars count, and API status).
- Designed responsive card grids for Repository listings (including color-coded Public/Preview visibility pill badges) and a right-column sidebar containing Quick Actions, Workspace Info, and Tier definitions.
- Update: Wrapped repository card components, file tree rows, and commit history rows in absolute overlay links so that their entire elements/boxes are fully clickable. Added hover transitions to underline their respective names/titles.
- Update: Removed the "Workspace Info" card from the right sidebar to clean up unnecessary backend details and keep the interface focused.
- Upgraded SiaoHub's main landing page (
- Full-Width Dark Navigation Bar:
- Refactored the global header (
site-header) into a sticky, full-width dark navigation bar (#161b22) with a custom sage-green gradient logo icon.
- Refactored the global header (
- Server-Action star and Watch Controls:
- Created server-side functions (
actions.ts) in/[owner]/[repo]to toggle Star/Watch status against the Forgejo API. - Linked them to client-side
StarWatchButtons.tsxusing React'suseTransitionfor instant UI state feedback. - Disabled actions and added informative tooltips for logged-out users, redirecting them to
/loginif clicked.
- Created server-side functions (
- Real Sidebar Metadata Integration:
- Implemented client API methods (
listContributors,listReleases,listTags) inForgejoClientto fetch authentic repository statistics. - Wired these into the repository home page's sidebar, removing mock lists and replacing them with real avatars, tags counts, and release logs.
- Redirected external tabs like Issues, PRs, Wiki, and Settings to their respective Forgejo locations using
FORGEJO_PUBLIC_URL.
- Implemented client API methods (
- "Go to File" (Keypress T) Fuzzy Finder:
- Built a client-side search dialog (
GoToFile.tsx) accessible via the toolbar button or by pressington the page. - Supports keyboard-accessible navigation (up/down arrow keys, Enter to navigate, Escape to dismiss) and filters files using case-insensitive fuzzy matching.
- Built a client-side search dialog (
- Dynamic ZIP Download Proxy:
- Implemented
/api/v1/repos/[owner]/[repo]/archive/[filename]/route.tsto fetch, authenticate, and stream ZIP archives directly from internal Forgejo using session tokens. - Added comprehensive test coverage in
route.test.tsto verify headers mapping and authentication forwarding.
- Implemented
- E2E & Unit Test Verification:
- Fixed E2E string assertions (e.g. lowercase
updated .* agoformat and emoji⭐ / 👀counts) to ensure compatibility. - Successfully validated 22 Unit tests and 29 Playwright E2E tests. Rebuilt and successfully deployed production containers.
- Fixed E2E string assertions (e.g. lowercase
- Update: Aligned Light Mode color theme exactly with siao.ai's paper-white styling (
#FAF9F6background,#1A1A1Aink text,#6E6B65ink-dim muted text, and#E5E2D9warm border color). Reconfigured the global sticky navigation header to turn to a paper-white theme in light mode. - Update: Removed all Forgejo-exposed web UI interactions, including the Fork button, Add file link, Quick Actions card on the homepage, and external tabs (Issues, PRs, Actions, Wiki, Settings), keeping SiaoHub as a pure read-only client while repository management runs purely via SSH.
- Update: Injected a blocking theme initialization script in
layout.tsx's<head>to set thedata-themeattribute before page paint, preventing visual layout flashing/flickering. Synced client state insideThemeToggle.tsxon mount, added smooth transitions for theme changes to body, cards, and list rows, and solved color contrast for.hero-stat-labelin light mode. - Update: Created the
RenUniversalrepository in Forgejo, successfully pushed the entire/Users/shihte.hsiao/Desktop/RenUniversalcodebase to it (including staging, committing, and pushing the previously untracked documentation filesWebCoding.md,iOSCoding.md, and helper scripts), deleted the mocksecret-notespreview repository, and removed theforkscount stat row from the repository side-panel. Disposed of fetch caching inForgejoClientto guarantee 100% real-time star and watch updates. - Update: Stopped local Docker Compose stack services. Cloned
siaohubcodebase to~/siaohubon the remote server, copied updateddocker-compose.ymlto the host, and executed the build/start of containers (forgejoandsiaohub) on the real server (Tailscale IP[redacted: address]). Configured a.envfile on the host and registered asiaohub-tailscaleOAuth2 application to allow authentication flows over the Tailscale network interface. Verifiedsiaohubis active and responsive on[redacted: address]:3001andforgejois active on[redacted: address]:3000.
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
- Update: Resolved the performance issue of file tree navigation by introducing Next.js caching to GET requests (
next: { revalidate: 10/60 }) while keeping user actions like star/watch count checking onno-storereal-time verification. - Update: Configured
PAGE_SIZEto read from the host environment variables (defaulting to20in production), while injectingPAGE_SIZE=2into e2e test parameters inplaywright.config.tsanddev-local.shto keep E2E tests fully green. - Update: Expanded
languageColor.tswith colors for Swift, Batchfile, Makefile, and Ruby. Integrated Gitea's languages API (/repos/{owner}/{repo}/languages) into the tree sidebar, displaying a multi-segment language progress bar with correct percentages. - Update: Pushed all 18 branches of
RenUniversalto Gitea, and updated the localoriginofRenUniversalto Gitea with token auth for instant future pushes. - Update: Refactored
tree,blob, andcommitsrouting to Next.js catch-all routes (tree/[[...treePath]],blob/[...blobPath],commits/[...commitsPath]), resolving branch names with slashes (e.g.,docs/system) using a custom resolution helperresolveBranchAndPaththat checks branch lists. - Update: Deleted duplicated
not-found.tsxlayouts and registered a global smartnot-found.tsxunder[owner]/[repo]utilizing a request-pathname-injecting Next.js 16 proxy (proxy.ts), which accurately presents 404 responses for Repository, Branch, Path, and File errors. - Update: Prevented Next.js OOM crashes when browsing binary or large files (e.g.
.taskmodel files and.ipafiles) by checking file extension lists and Gitea'ssizeproperty withingetFileContent, displaying a styled binary placeholder card inline instead of trying to parse base64 buffers as UTF-8 text. - Update: Reverted login gating on the SiaoHub web portal. Users can access public repositories directly without forced login redirection. Restored Playwright E2E tests to their public access suite assertions, keeping all 29 tests fully green.
- Update: Deleted the
siaohubcodebase repository itself from the Gitea database and remote storage. Cleared theorigingit remote of the localsiaohubrepository to eliminate security vulnerabilities and prevent accidental pushes.
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
- Next.js 16 Proxy (Middleware) Fix:
- Identified that Next.js 16 deprecated
middleware.tsin favor ofproxy.ts. Because both files existed insrc/, Turbopack compilation was failing. Removedsrc/middleware.tsand successfully compiledsrc/proxy.ts(as the single Proxy middleware). - This fixed the request pathname forwarding (
x-pathname), ensuring that any subsequent custom 404 views receive correct paths and do not fallback to generic error segments.
- Identified that Next.js 16 deprecated
- Support for Branch Names with Slashes:
- Found that branch names containing slashes (e.g.
docs/system) broke API request URLs. Extended URL-encoding to all remainingForgejoClientref-utilizing methods:listCommits,getFileContent, andgetLastCommitForPath. - Updated the commits history view query parsing to safely filter commits by paths containing slashes and pass sub-paths to the pagination component.
- Rebuilt and verified locally with curl and Playwright.
- Found that branch names containing slashes (e.g.
- LICENSE Detection & Sidebar Integration:
- Added root-level
LICENSEfile detection (matching/^license(\.md|\.txt)?$/ifiles). - Integrates the detected license dynamically into the repository About sidebar card next to a Law Icon (
LawIconfrom@primer/octicons-react).
- Added root-level
- LICENSE Parsed Metadata Card View:
- Built a custom card at the top of the file view page when displaying a license file.
- Automatically parses the license name (such as MIT, Apache 2.0) and dynamically renders Permissions, Limitations, and Conditions in a visually premium tabbed layout.
- E2E & Unit Test Verification:
- Added a dedicated E2E test verifying both the sidebar LICENSE link presence and the parsed MIT License details card.
- All 29 Playwright E2E tests pass cleanly.
- Production Rebuild & Deploy:
- Pushed the commits to the remote repo and triggered a clean Docker build (
docker compose build --no-cache siaohub) on the remote server ([redacted: address]). - Verified with curl that branch trees, blobs, and commits containing slashes correctly return HTTP
200 OKand show the custom LICENSE cards on the remote server.
- Pushed the commits to the remote repo and triggered a clean Docker build (
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.md — main 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 about →
Person → https://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
- exp only;
authorizeGitRequestnever consulted the revocation store, so a 90-day clone token kept working after logout / suspension. Fixed: the token now carriesiatandauthorizeGitRequestrefuses one minted at/before the account'ssignedOutAt— the same<=rulegetCurrentSessionalready uses. Narrow anomaly (GitHub PATs also survive a web logout); the real gap was suspension. Zero live exposure (approvals empty).