Internal developer portal — a public-facing website whose primary users are
internal employees. The repository, package, and databases are named porta,
which is the codename rather than the product name (ADR 0009). It is built as
three feature modules over one generic content model, each owned end to end by
a different team and wired in by a single line of a registry (ADR 0013):
- Tool Shelf (工具目录)
/tools— the supported tools, filterable by category, maturity and language - Handbook (文档与快速上手) —
/docs, reference and task-shaped guides grouped by kind; and/start, guided tracks split into ordered steps - Events (活动)
/events— office hours, workshops, migration clinics and quarterly reviews, upcoming and past
Two more pages belong to no module: the overview at /, and /adoption,
which reports what the catalog contains and where it is thin — counted from
the catalog itself, since there is no usage telemetry (ADR 0018).
More modules are expected; adding one touches no core file.
Public visitors can browse the overview, the section listings and /adoption;
opening a resource's full content requires sign-in. Sign-up is invite-only
(admins create copyable invite links), and content is managed in a role-gated
admin area with per-locale English/中文 editing.
TypeScript · Next.js (App Router) · Tailwind CSS + shadcn/ui · next-intl (en/zh) · Postgres + Drizzle ORM · better-auth · Bun (dev tooling) / Node (production runtime)
Prerequisites: Bun and a local Postgres.
bun install
cp .env.example .env # then set DATABASE_URL and BETTER_AUTH_SECRET
createdb porta
bun run db:migrate # apply committed SQL migrations
bun run db:seed # optional sample content
bun devThe first account created at /en/sign-up becomes the administrator; after
that, sign-up requires an invite link created in /admin/invites.
| Script | Purpose |
|---|---|
bun run verify |
The landing gate: format check, lint, typecheck, i18n parity, unit + DB tests |
bun run test / test:unit / test:db |
bun test suites (DB suites use a dedicated porta_test database) |
bun run test:e2e |
Playwright suite against a production build + throwaway porta_e2e database |
bun run format |
Biome format + import organizing |
bun run db:generate |
Generate SQL migrations from schema changes |
bun run db:migrate |
Apply migrations (plain Node, same script as production) |
bun run db:seed |
Seed sample content (wipes content tables, keeps users) |
bun run auth:schema |
Regenerate src/db/schema/auth.ts from the better-auth config |
bun run i18n:check |
Verify en/zh message files have identical keys |
Vercel — vercel.json sets the build command to run migrations before
next build. Set DATABASE_URL, BETTER_AUTH_SECRET, and BETTER_AUTH_URL
in the project environment. Use a plain TCP Postgres URL (Neon works; set
DATABASE_POOLED=1 for pooled endpoints). Only production builds migrate —
preview builds skip the step and expect an already-migrated database, so
they never write to the database production shares. Leave BETTER_AUTH_URL
unset for Preview: pinning it to the production origin makes preview
deployments fail better-auth's origin check. Previews still emit absolute
email links — auth falls back to VERCEL_PROJECT_PRODUCTION_URL (a system
variable; keep "Automatically expose System Environment Variables" on) and
trusts the preview's own origin so sign-in keeps working.
Email (password reset, invites) needs two more variables. The Resend
Marketplace integration provisions RESEND_API_KEY; you must also set
EMAIL_FROM to a sender on a Resend-verified domain — it is required
whenever RESEND_API_KEY is set (there's no implicit fallback sender, so a
missing EMAIL_FROM makes every invite and reset email silently skip). Leave
both unset to disable email — the flows then log a redacted skip status (the
subject only, never the link, which carries a token) instead of sending. To
inspect reset/invite links locally, point Resend at a dev mail catcher.
Two optional variables tune the security posture. CSP_REPORT_ONLY=1 sends
the Content-Security-Policy as report-only — violations reach the browser
console and nothing is blocked; set it for a deploy or two after changing the
policy, then unset it to enforce. TRUST_PROXY_HEADERS=1 tells auth rate
limiting that a reverse proxy you control rewrites x-forwarded-for, so
limits key on the real client address; Vercel is detected automatically and
needs nothing here. Set it only when both are true: a proxy rewrites the
header, and the app cannot be reached except through that proxy — bind it to
the proxy's network or to loopback. If the container is still published on
every interface (as docker-compose.yml does by default), a client can go
around the proxy, forge the header and mint a fresh bucket per request, which
is worse than leaving the flag off. Unset, every client shares one bucket per
endpoint: safe but coarse, and the reason to run behind a proxy in the first
place. See
ADR 0014.
Docker — the app also runs as a self-hosted container (Next.js standalone output, migrations applied on start):
BETTER_AUTH_SECRET=$(openssl rand -base64 32) docker compose up --buildTo send email from the container, pass RESEND_API_KEY and EMAIL_FROM
through (see .env.example); docker-compose.yml already forwards both.
Without them, email is skipped rather than sent.
Constraint to preserve: no Vercel-only service dependencies, and no
NEXT_PUBLIC_* env vars for environment-dependent values — all config is
read from server env at runtime so one image runs anywhere.
- CONTEXT.md — domain vocabulary
- docs/architecture.md — system overview, conventions, deployment constraints, and how to add a module or a section
- docs/adr/ — decision records