OSBLOG LIBRARY

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

DOC PAGE

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.tsx hydrates the SSR HTML in the browser; src/entry-server.tsx renders every route to a string via renderToString and builds the full HTML document (SEO tags, JSON-LD, hashed asset links) via src/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.ts adapts the Request/Response-based router to Node's IncomingMessage/ServerResponse, used by both tools/server/start.ts and, indirectly, by the Vercel/Netlify adapters (which run the same built router in their own request model).
  • api/index.ts and netlify/functions/osblog.mts are thin re-exports of the built dist/server/index.js handler — neither contains routing logic of its own.
  • drizzle/ owns the five SQL migrations now applied to production, including 0004_post_slug_history.sql, verified locally under UA-0073/UA-0077 and rolled out under UA-0080. src/server/provision.ts owns 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.

RouteAccessOutput and behavior
/publicBilingual home: latest published posts, categories, archive years. SSR.
/archivepublicSame listing shape as home, framed as the full archive. SSR.
/category/:slugpublicPublished posts filtered to one category. SSR.
/search?q=publicBounded server-side search across title/excerpt (both languages); noindex,follow.
/post/:slugpublicCurrent visible article returns SSR; a visible historical slug returns a one-hop 308 to its current URL. Hidden or unknown slugs return 404.
/aboutpublicStatic bilingual about page.
/docs, /docs/:slugpublicThis documentation set, read from docs/**/*.md; ?lang=vi selects the Vietnamese variant.
/admin/loginunauthenticatedBetter Auth sign-in only; no public registration.
/admin, /admin/posts, /admin/posts/new, /admin/posts/:id/edit, /admin/categories, /admin/commentsadminThe real publishing workspace (React Router app under src/app/admin/); redirects to /admin/login when unauthenticated.

API handlers, all under /api/:

Method and pathBoundary
GET /api/posts, GET/HEAD /api/posts/slug/:slugPublic published reads only; historical slugs redirect with 308 to the current JSON endpoint.
GET /api/categoriesPublic read of active categories.
POST /api/posts/:id/viewRate-limited (1 per IP per post per 24h), increments view_count.
GET /api/comments/tokenIssues a signed, time-boxed comment form token.
GET /api/comments?postId=, POST /api/commentsPublic read of approved comments; anonymous submission, always pending/spam.
GET/POST /api/admin/posts, GET/PATCH/DELETE /api/admin/posts/:idAdmin-only post CRUD; DELETE archives.
GET/POST /api/admin/categories, PATCH/DELETE /api/admin/categories/:idAdmin-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/:idAdmin-only moderation; DELETE is a real, permanent delete.
GET /api/admin/sessionReturns the signed-in admin's email.
/api/auth/sign-in/email, /api/auth/sign-out, /api/auth/get-sessionThe only three Better Auth endpoints exposed — no public sign-up surface.
GET /api/healthzLiveness/DB-connectivity check (select 1); no secrets or database contents.
GET /sitemap.xml, GET /robots.txtGenerated 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 — UUID id, unique slug, bilingual name/description, isArchived flag, timestamps.
  • post — UUID id, categoryId (FK), unique slug, 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) and categoryId. Only rows where status = 'published', publishedAt <= now(), and the owning category is not archived are publicly visible (visiblePost() in src/server/content.ts) — a future publishedAt is a real, working way to schedule a post.
  • comment — UUID id, postId (FK), encrypted email (emailCiphertext) plus emailHash for dedup/rate-limit lookups, body text, status (pending|approved|rejected|spam), hashed IP and user-agent, reviewedAt, moderationReason, timestamps. Indexed on postId, (status, createdAt), and emailHash.
  • Better Auth tables (user, session, account, verification) — exactly one admin user; a databaseHooks.user.create.before hook additionally rejects creating any account whose email doesn't match ADMIN_EMAIL, on top of disableSignUp.
  • 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

  1. POST /api/auth/sign-in/email accepts 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, Secure when the auth origin is https://, expiring after 8 hours and refreshing after 1 hour of use.
  2. The admin app calls GET /api/admin/session; a 401 redirects to /admin/login. Hiding controls in React is never treated as authorization — every admin route handler calls the same admin(request) check in router.ts.
  3. Each mutation parses JSON with a shared Zod schema, checks optimistic concurrency against expectedUpdatedAt, runs the write inside a database transaction, writes an audit_event row, and returns the saved record.
  4. Category delete/archive is rejected with 409 when 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.
  5. 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 body at 5,000 characters and email at 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 get 429. 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 starting pending or spam — never auto-approved.
  • Turnstile is not wired in. TURNSTILE_SECRET_KEY is 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"> or lang="en" per the ?lang= query parameter, with hreflang alternate links for both languages.
  • A canonical URL (query parameters other than lang/page/year/category stripped), 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: BlogPosting for articles (headline, dates, author, image), TechArticle for documentation pages, WebSite otherwise.
  • robots meta is noindex,follow for non-200 responses, /admin*, /login, error pages, /search, and sorted (random/popular) listing views; everything else is index,follow.
  • /sitemap.xml includes only visible posts (see visiblePost()), the documentation index and pages, static routes, and both language variants of each, with lastmod from updated_at; capped with an explicit 503 if 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.txt disallows /admin and /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.