osblog application architecture
osblog is a Vite + React 19 + TypeScript application with one shared request router ([`src/server/router.ts`](https://github.com/thuanlyt/osblog/blob/main/src/server/router.ts)) th
Tiếng Việt (tóm tắt): docs/vi/architecture.md
Status: implemented and reviewed. This replaces an earlier scaffold-era version of this document that described api/render.ts, per-route Vercel functions, and a placeholder admin UI — none of that shape exists anymore. What is described below is the current single-router architecture, verified locally with 64 passing unit/component/SQL integration tests plus 2 compiled-browser E2E tests. The GitHub-linked Vercel production deployment on main is live on both requested hostnames and passed the dated route smoke recorded in Deployment.
Decision in one paragraph
osblog is a Vite + React 19 + TypeScript application with one shared request router (src/server/router.ts) that handles every path — public pages, the documentation site, the admin app, and the JSON API — behind a single Request -> Response function. That router is reused unmodified across three runtimes: a Node HTTP server in development and on a VPS (tools/server/start.ts), a single Vercel Node.js Function (api/index.ts), and a single Netlify Fetch function (netlify/functions/osblog.mts). Persistence uses Neon Postgres via Drizzle ORM's HTTP driver; admin authentication uses Better Auth with public sign-up disabled and one operator-bootstrapped identity. Public routes render server-side with renderToString so crawlers receive real article HTML and metadata; the same React tree hydrates in the browser.
Runtime and ownership boundaries
Browser
├─ React UI and hydration (src/app/)
├─ public read requests ───────────────┐
└─ admin/comment form requests ────────┤
v
Shared request router (src/server/router.ts)
├─ request parsing, auth, CSRF/origin checks, validation, security headers
├─ route dispatch: API JSON, SSR HTML, sitemap/robots
└─ server-only services ──> Neon Postgres (Drizzle)
^
│ same router, three thin adapters
┌─────┴──────┬───────────────────┬─────────────────────┐
Node HTTP Vercel Function Netlify Function
(dev/VPS) (api/index.ts) (netlify/functions/osblog.mts)
src/app/owns React routes, layouts, the public component tree, and the admin app (src/app/admin/).src/entry-client.tsxhydrates the SSR HTML in the browser;src/entry-server.tsxrenders every route to a string viarenderToStringand builds the full HTML document (SEO tags, JSON-LD, hashed asset links) viasrc/server/seo.ts.src/server/owns the router, schema, query services (content.ts,comments.ts,docs.ts,pages.ts), validation contracts, and auth configuration. It is imported only by server entry points and must never be bundled for the client.src/server/node-adapter.tsadapts theRequest/Response-based router to Node'sIncomingMessage/ServerResponse, used by bothtools/server/start.tsand, indirectly, by the Vercel/Netlify adapters (which run the same built router in their own request model).api/index.tsandnetlify/functions/osblog.mtsare thin re-exports of the builtdist/server/index.jshandler — neither contains routing logic of its own.drizzle/owns the five SQL migrations now applied to production, including0004_post_slug_history.sql, verified locally under UA-0073/UA-0077 and rolled out under UA-0080.src/server/provision.tsowns migration execution, slug-history preflight, admin bootstrap, and the optional content seed.
Routes
Route resolution happens in src/server/pages.ts (loadPage), which is data-driven rather than a client-side route table — it inspects the URL and returns a typed PageData payload that src/app/App.tsx renders by kind.
| Route | Access | Output and behavior |
|---|---|---|
/ | public | Bilingual home: latest published posts, categories, archive years. SSR. |
/archive | public | Same listing shape as home, framed as the full archive. SSR. |
/category/:slug | public | Published posts filtered to one category. SSR. |
/search?q= | public | Bounded server-side search across title/excerpt (both languages); noindex,follow. |
/post/:slug | public | Current visible article returns SSR; a visible historical slug returns a one-hop 308 to its current URL. Hidden or unknown slugs return 404. |
/about | public | Static bilingual about page. |
/docs, /docs/:slug | public | This documentation set, read from docs/**/*.md; ?lang=vi selects the Vietnamese variant. |
/admin/login | unauthenticated | Better Auth sign-in only; no public registration. |
/admin, /admin/posts, /admin/posts/new, /admin/posts/:id/edit, /admin/categories, /admin/comments | admin | The real publishing workspace (React Router app under src/app/admin/); redirects to /admin/login when unauthenticated. |
API handlers, all under /api/:
| Method and path | Boundary |
|---|---|
GET /api/posts, GET/HEAD /api/posts/slug/:slug | Public published reads only; historical slugs redirect with 308 to the current JSON endpoint. |
GET /api/categories | Public read of active categories. |
POST /api/posts/:id/view | Rate-limited (1 per IP per post per 24h), increments view_count. |
GET /api/comments/token | Issues a signed, time-boxed comment form token. |
GET /api/comments?postId=, POST /api/comments | Public read of approved comments; anonymous submission, always pending/spam. |
GET/POST /api/admin/posts, GET/PATCH/DELETE /api/admin/posts/:id | Admin-only post CRUD; DELETE archives. |
GET/POST /api/admin/categories, PATCH/DELETE /api/admin/categories/:id | Admin-only category CRUD; DELETE archives; referenced categories cannot be archived by another route's cascade — deletion is rejected with 409 if posts still reference an active category. |
GET /api/admin/comments, PATCH /api/admin/comments/:id, DELETE /api/admin/comments/:id | Admin-only moderation; DELETE is a real, permanent delete. |
GET /api/admin/session | Returns the signed-in admin's email. |
/api/auth/sign-in/email, /api/auth/sign-out, /api/auth/get-session | The only three Better Auth endpoints exposed — no public sign-up surface. |
GET /api/healthz | Liveness/DB-connectivity check (select 1); no secrets or database contents. |
GET /sitemap.xml, GET /robots.txt | Generated from published rows and the documentation set; robots disallows /admin and /api/. |
API responses use a stable { data, error, requestId }-shaped envelope (see src/server/http.ts). Validation errors are 400, unauthenticated 401, unauthorized 403, missing records 404, conflicts 409, rate limits 429, and unexpected failures a generic 500/503 with a server-side request ID.
Entities and invariants
Schema source: src/server/schema.ts.
category— UUIDid, uniqueslug, bilingual name/description,isArchivedflag, timestamps.post— UUIDid,categoryId(FK), uniqueslug, bilingual title/excerpt/body, cover image URL + bilingual alt text,status(draft|published|archived),publishedAt,viewCount, bilingual SEO title/description, timestamps. Indexed on(status, published_at)andcategoryId. Only rows wherestatus = 'published',publishedAt <= now(), and the owning category is not archived are publicly visible (visiblePost()insrc/server/content.ts) — a futurepublishedAtis a real, working way to schedule a post.comment— UUIDid,postId(FK), encrypted email (emailCiphertext) plusemailHashfor dedup/rate-limit lookups, body text,status(pending|approved|rejected|spam), hashed IP and user-agent,reviewedAt,moderationReason, timestamps. Indexed onpostId,(status, createdAt), andemailHash.- Better Auth tables (
user,session,account, verification) — exactly one admin user; adatabaseHooks.user.create.beforehook additionally rejects creating any account whose email doesn't matchADMIN_EMAIL, on top ofdisableSignUp. rate_limit_bucket— normalized key, window start, count, expiry; used atomically for comment submission, sign-in attempts, and per-post view counting.audit_event— actor user ID, action, entity, entity ID, before/after JSON summary, request ID, timestamp. Never stores raw passwords, tokens, or comment email.
Published content is not literally immutable, but every edit bumps updated_at (nextTimestamp, strictly increasing) and is guarded by optimistic concurrency (expectedUpdatedAt) so concurrent edits are rejected as 409 rather than silently lost.
Permanent published-slug ownership
post_slug_history maps each ever-published slug directly to post.id, with a primary key on slug, a post_id index, and the earliest known first_published_at. The registry includes the current slug once published, including scheduled posts. An AFTER INSERT OR UPDATE OF slug, status database trigger takes transaction advisory lock 62874110, checks ownership and records publication. Direct SQL and older application instances receive the same guard. The existing current-slug unique constraint still applies.
Published slugs cannot be reused by another post or reclaimed by the same post. This prevents loops from previously cached permanent redirects. Renaming a to b to c leaves both a and b pointing to the immutable post ID; the resolver reads its current slug c, never another alias. Draft-only names are not reserved. Unpublish/archive retains all prior ownership; the application does not hard-delete posts. An explicitly authorized hard deletion cascades history and changes this retention guarantee.
content.ts:resolvePublishedSlug applies the same publication date, status and category visibility predicate as public reads. Historical HTML GET/HEAD requests return 308 to the absolute current /post/:slug?lang=en|vi, using the configured SSR origin, selecting vi only when explicitly requested, and dropping unrelated query parameters. Historical API GET/HEAD requests redirect to /api/posts/slug/:current, without query parameters, so redirect-following clients still receive JSON. Hidden targets return 404 with no Location, including their historical URLs. Redirects keep Cache-Control: no-store. Only the final current page renders; canonical/hreflang/Open Graph/JSON-LD and sitemap URLs therefore contain only the current slug.
The editor keeps the saved slug as its baseline and displays a non-blocking warning when an existing slug changes. Its preview uses the proposed URL. History conflicts raise SQLSTATE 23505, preserving HTTP 409 SLUG_TAKEN and the slug field error; the form stays dirty and typed content remains available. A successful save resets the baseline. Seed reruns skip reserved names, so renaming a published seed does not recreate it.
Source anchors: drizzle/0004_post_slug_history.sql, schema.ts:postSlugHistory, content.ts:resolvePublishedSlug, the post routes in router.ts, provision.ts:preflightSlugHistory / seedIntroduction, and AdminPostEditorPage.tsx.
Auth and CRUD flow
POST /api/auth/sign-in/emailaccepts credentials only for the single pre-created admin user, rate-limited by hashed IP and hashed email (15 attempts / 15 minutes each). Better Auth issues a session cookie,Securewhen the auth origin ishttps://, expiring after 8 hours and refreshing after 1 hour of use.- The admin app calls
GET /api/admin/session; a401redirects to/admin/login. Hiding controls in React is never treated as authorization — every admin route handler calls the sameadmin(request)check inrouter.ts. - Each mutation parses JSON with a shared Zod schema, checks optimistic concurrency against
expectedUpdatedAt, runs the write inside a database transaction, writes anaudit_eventrow, and returns the saved record. - Category delete/archive is rejected with
409when active posts still reference it. Post and category delete archive by default; there is no hard-delete action for either in the current admin UI. Comment delete is a real, permanent delete. - Sign-out revokes the session. Session cookies, CSRF/origin failures, and admin role failures are covered by
tests/server/auth.test.ts.
Anonymous comments, moderation, and anti-spam
The form asks for email and comment text only; there is no commenter account. Implemented protections, in order of what actually runs today:
- Server-side validation caps
bodyat 5,000 characters andemailat 320; Markdown/HTML in comment bodies is rendered through the same sanitized renderer used for posts, never raw HTML. - A server-issued, HMAC-signed form token (
GET /api/comments/token) expires after 15 minutes; submissions with a missing, expired, or forged token are rejected. - A honeypot field is present but is one signal among several, never the only spam check.
- An atomic, database-backed rate limit (
rate_limit_bucket) keyed by hashed IP and hashed normalized email; over-limit requests get429. Neither raw IP nor raw email is ever logged. - The moderation state machine is
pending -> approved|rejected|spam, with moderator action, timestamp, and reason, always startingpendingorspam— never auto-approved. - Turnstile is not wired in.
TURNSTILE_SECRET_KEYis accepted by the environment schema but no code path verifies it yet — see Configuration. Do not describe comment submission as CAPTCHA-protected. - There is no outbound moderation-notification email; moderators check the queue at
/admin/comments.
SEO and crawl output
Implemented in src/server/seo.ts (renderDocument, used by every SSR response via src/entry-server.tsx):
<html lang="vi">orlang="en"per the?lang=query parameter, withhreflangalternate links for both languages.- A canonical URL (query parameters other than
lang/page/year/categorystripped), unique per-page title and description, Open Graph and Twitter Card metadata, and a responsive cover image with alt text when a post has one. - JSON-LD:
BlogPostingfor articles (headline, dates, author, image),TechArticlefor documentation pages,WebSiteotherwise. robotsmeta isnoindex,followfor non-200 responses,/admin*,/login, error pages,/search, and sorted (random/popular) listing views; everything else isindex,follow./sitemap.xmlincludes only visible posts (seevisiblePost()), the documentation index and pages, static routes, and both language variants of each, withlastmodfromupdated_at; capped with an explicit503if a single sitemap would exceed roughly 49,000 URLs (partitioning is not implemented, and is not expected to be needed at this project's scale)./robots.txtdisallows/adminand/api/.
Environment variables
See Configuration for the complete, current table. In summary: DATABASE_URL (pooled) and DATABASE_URL_MIGRATIONS (direct, operator-only, falls back to DATABASE_URL) are never sent to the browser; BETTER_AUTH_SECRET, BETTER_AUTH_URL, ADMIN_EMAIL, and COMMENT_EMAIL_ENCRYPTION_KEY are required in production and fail closed if missing; only VITE_SITE_URL is intended to reach the client bundle.
Migration, seed, and rollback
npm run db:migrate # applies drizzle/*.sql inside one transaction, advisory-locked, checksum-tracked
npm run db:bootstrap # creates the single admin account; never overwrites an existing one
npm run db:seed # optional, idempotent bilingual introduction content
Migrations 0000_durable_content.sql through 0003_auth_issuer.sql have run against the provisioned Neon database and replay is idempotent (see Backups and rollback). None of these operations runs automatically as part of npm run build or any deployment — they are explicit operator steps.
Migration 0004_post_slug_history.sql is an additive registry now applied to production. It locks post/audit writes while backfilling current published slugs and valid before_summary.slug values whose audited status was published. Candidates join to an existing post by ID, are deduplicated by slug/owner and retain the earliest available timestamp. Missing targets and malformed audit slugs are excluded. A slug associated with multiple posts, or with a different current owner (even a draft), aborts the entire migration before any changes commit. The rollout preflight returned zero ownership conflicts; an operator must reconcile any future ambiguity and the migration never chooses a winner.
Before any future schema rollout, call the read-only preflightSlugHistory(db) helper with the operator's database handle. It returns counts only: malformed published audit candidates, missing targets, multiple historical owners, and conflicts with current owners. It can run on the pre-0004 schema. The UA-0080 rollout reviewed skipped evidence, verified a disposable Neon branch restore and recorded zero ownership conflicts before applying 0004. Local PGlite tests exercise competing application calls but serialize database sessions; they do not measure provider lock contention. Native dump/restore and provider contention load testing remain open.
Apply the additive migration before deploying redirect-aware code. Older instances then preserve publication history through the trigger. A stale seed invocation racing a rename can still receive a collision; retry the idempotent seed after the concurrent write completes. Audit records cannot recover unaudited renames, deleted posts or missing historical events. Preservation for those unknown names starts at migration time, unless an operator separately supplies a reviewed mapping.
For application rollback, keep the registry and trigger: old code remains protected against reuse, though historical URLs return 404 until redirect-aware code is restored. Dropping the registry/trigger would lose ownership and reopen slugs, and is not a routine rollback. It requires explicit operator approval and reconciliation/restoration. These rollout requirements supersede the older editor guide's statement that slug redirects are unavailable once migration 0004 and this code are deployed.
Rollback is two-dimensional: for code-only failures, point the deployment target back at the last known-good build and smoke-test; for schema changes, keep rollback code backward-compatible with the deployed schema rather than running an unreviewed destructive migration. Full detail in Backups and rollback.
Verification boundaries
npm ci
npm run lint
npm run typecheck
npm test # 93 unit/component/SQL integration tests as of 2026-09-05
npm run build
npm run test:e2e # 2 compiled-browser E2E tests as of 2026-09-05
What is verified as of 2026-09-05: schema, environment parsing, auth policy, comment policy, content contracts, HTTP envelopes, SEO output, admin editor component behavior, SQL-backed integration tests against the real schema, compiled-browser publishing/moderation, responsive docs, Axe checks, production build output, and Vercel/Neon production route smoke on both requested hostnames. What is not yet verified: provider rollback/restore exercises and non-Vercel adapter execution (see Deployment and Backups and rollback).
Known gaps and next action
- Positive live historical-alias evidence remains open. Registry, one-hop redirects and editor feedback are deployed; local SQL/browser tests prove the redirect path and live current/unknown safety smoke passes, but production has no existing old-slug alias to test without an intentional content rename.
- Turnstile is unwired. The secret is accepted but unused; comments are not CAPTCHA-protected today.
- Non-Vercel and provider recovery remain open. Netlify's adapter and the VPS path have not been exercised on their live platforms; Neon backup/restore and provider lock/contending-session checks remain pending. Vercel alias rollback has been rehearsed successfully. See Deployment and Backups and rollback.
- Coverage boundary. The compiled-browser gate proves the tested publishing/comment/moderation and responsive-docs flows; it does not replace provider rollback/restore drills or a full exploratory audit of every admin screen.
Next action: document and run a backup/rollback drill before the next material schema change, then consider Turnstile and non-Vercel deployment as separate follow-on work.