Proof of concept · Demo · not production
Vercel frontend + Portal API + Neon PostgreSQL
The hosted Demo of the Raisd portals: four portal webs and the Portal API on Vercel (Singapore), with Neon Postgres used only by the API for sessions. Does not replace UltaHost + k3s (SDD-12 / ADR-2 / ADR-3).
Live targets
| Component | URL | Repo | Runtime | Deploy path |
|---|---|---|---|---|
| Campus landing (Lesotho) | lesotho.raisd.co (legacy raisd-campus-landing.vercel.app) |
campus-landing | Static HTML chooser → Apply / Student / Staff / Teach | Manual: npm run deploy from a local checkout |
| Student portal | raisd-student-portal.vercel.app · planned student.lesotho.raisd.co |
student-portal | Vite + React SPA, static prebuilt output | GitHub Actions CI/CD on push to main |
| Applicant portal | raisd-applicant-portal.vercel.app · planned apply.lesotho.raisd.co |
applicant-portal | Vite + React SPA; HTTP Portal API when VITE_PORTAL_API_URL is set |
Manual prebuilt deploy (same CI/CD template; Actions billing may block CI) |
| Lecturer portal | raisd-lecturer-portal.vercel.app · planned teach.lesotho.raisd.co |
lecturer-portal | “Coming soon” placeholder until the repo has an app | GitHub Actions CI/CD on push to main |
| Staff portal | raisd-staff-portal.vercel.app · planned staff.lesotho.raisd.co |
staff-portal | “Coming soon” placeholder until the repo has an app | GitHub Actions CI/CD on push to main |
| Portal API | raisd-portal-api.vercel.app | portal-api | Fastify in one Node 22 Vercel Function, region sin1, 30 s max |
Manual: npm run deploy:vercel from a local checkout (CI runs checks only) |
| Database | No public URL | — | VPS Postgres 18 (portal_api; Neon dump restored) |
Provisioned via the Vercel Neon integration on the portal-api project |
| DB admin | raisd-db-admin.vercel.app (team only) | db-admin — read-only browse / SQL / ERD with CMS·LMS·shared colours | Vercel Functions, sin1; read-only table browser + SQL |
Manual: npm run deploy from a local checkout (no CI/CD) |
| Docs & OpenAPI | raisd-campus.github.io/portal-api-docs | portal-api-docs | GitHub Pages, site password gate | GitHub Actions Pages on push to main |
All Vercel projects live in one team and are not Git-connected: deploys only happen through the workflows or manual commands below, always as prebuilt output (vercel deploy --prebuilt --prod). Public routes are listed on the overview.
Target deployment
Portals still never talk to Postgres or the CMS directly. Only the Portal API (and the read-only DB admin) hold a connection string.
flowchart TB
user(["Browser"])
team(["Raisd team"])
subgraph gh ["GitHub · raisd-campus"]
repos["Portal repos
ci-cd.yml"]
apirepo["portal-api
ci.yml (checks)"]
pkg[("GitHub Packages
@raisd-campus/design-system")]
pages["portal-api-docs
GitHub Pages"]
end
subgraph vercel ["Vercel team · Singapore sin1"]
land["raisd-campus-landing
Lesotho chooser"]
sp["raisd-student-portal
static SPA"]
ap["raisd-applicant-portal
static SPA"]
lp["raisd-lecturer-portal
placeholder"]
stp["raisd-staff-portal
placeholder"]
api["raisd-portal-api
Node 22 function · Fastify
Student + Applicant Demo engines"]
dba["raisd-db-admin
read-only · sign-in"]
end
subgraph neon ["Neon · aws-ap-southeast-1"]
db[("neondb · Postgres 18 · schema v4
record tables · 4 views · audit_events
student_portal_state · applicant_portal_state
portal_meta · poc_sessions · poc_applicant_sessions")]
end
subgraph prod ["Production — not part of this PoC"]
cms[("Cyberjaya CMS
system of record · ADR-1")]
ulta["UltaHost Singapore + k3s
ADR-2 / ADR-3 · SDD-12"]
end
user -- HTTPS --> land
land --> sp
land --> ap
land --> lp
land --> stp
user --> sp
user --> ap
user --> lp
user --> stp
sp -- "PortalApi RPC · bearer
VITE_PORTAL_API_URL" --> api
ap -- "ApplicantPortalApi RPC · bearer
VITE_PORTAL_API_URL" --> api
pages -. "Swagger Try it out" .-> api
api -- "DATABASE_URL (pooled, TLS)" --> db
team -- "sign-in (session cookie)" --> dba
dba -- "raisd_db_admin_reader
READ ONLY" --> db
repos -- "npm ci" --> pkg
repos -- "vercel deploy --prebuilt" --> sp
team -- "npm run deploy:vercel" --> api
sp -. never .-x db
api -. no Live writes .-x cms
classDef poc fill:#e8f0f8,stroke:#1a4f8b,color:#152033
classDef store fill:#eef7ee,stroke:#2f7d32,color:#152033
classDef out fill:#f6f6f6,stroke:#9aa5b1,color:#5a6a7e,stroke-dasharray:4 3
class land,sp,ap,lp,stp,api,dba poc
class db,pkg store
class cms,ulta out
Solid arrows are live traffic or deploys. Crossed dashed arrows are forbidden paths; the grey group is production, which this PoC does not touch.
Vercel projects
| Project | Output | Routing | Environment variables (names only) |
|---|---|---|---|
student-portal |
npm run build:vercel → dist/ as .vercel/output/static |
Immutable cache on /assets/*; missing hashed chunk → 404; everything else → index.html |
VITE_PORTAL_API_URL baked in at build (build fails if unset, so MockPortalApi never ships) |
applicant-portal |
npm run build → dist/ packaged by the workflow |
Filesystem, then SPA fallback to index.html |
VITE_PORTAL_API_URL (build time) |
lecturer-portal · staff-portal |
Generated “Coming soon” page (no package.json yet) |
Static | — |
portal-api |
scripts/build-vercel.mjs: esbuild bundles src/vercel.ts plus the student and applicant Demo engines into functions/index.func |
All paths → the one function; Fastify routes inside (/v1/portal/:method, /v1/applicant/:method) |
DATABASE_URL / DATABASE_URL_UNPOOLED + PG* / POSTGRES_* (Neon integration), CORS_ORIGIN. Student and applicant demo passwords are inlined at build from gitignored credentials |
db-admin |
Zero-build Vercel Functions (api/*.ts) + public/ icons |
/ → UI function; noindex, no framing, no-store |
DATABASE_URL (reader role), DB_ADMIN_USER, DB_ADMIN_PASSWORD |
CORS_ORIGIN on the API includes the *.vercel.app student and applicant SPAs, the planned Lesotho campus hostnames (student.lesotho.raisd.co, apply.lesotho.raisd.co, staff.lesotho.raisd.co, teach.lesotho.raisd.co), and https://raisd-campus.github.io for Swagger. Working Demo path today is *.vercel.app; custom DNS is attached on Vercel and waiting on Cloudflare CNAMEs. Add an origin there before another portal calls the API from a browser.
CI/CD pipelines
flowchart LR
subgraph portals ["Portal repos · ci-cd.yml"]
pr1["Pull request"] --> chk["Check
npm ci · lint · test · build"]
push1["Push to main"] --> chk
chk -- "main only" --> dep["Deploy
build Vercel output
vercel deploy --prebuilt --prod"]
dep --> v1(["raisd-<repo>.vercel.app"])
end
subgraph api ["portal-api · ci.yml"]
pr2["PR / push to main"] --> smoke["Checkout api + student-portal + control-plane
npm ci · docs:check · boot + smoke"]
dev(["Developer laptop"]) --> man["npm run deploy:vercel"]
man --> v2(["raisd-portal-api.vercel.app"])
end
subgraph docs ["portal-api-docs · pages.yml"]
push3["Push to main"] --> lint["Redocly lint openapi.yaml
validate design-system pages"]
lint --> pub["Assemble _site + site auth
deploy-pages"]
pub --> v3(["GitHub Pages"])
end
dev --> man2["npm run deploy"] --> v4(["raisd-db-admin.vercel.app"])
| Repo · workflow | Triggers | Checks | Deploy | Repo configuration |
|---|---|---|---|---|
student / applicant / lecturer / staff portal · CI/CD (.github/workflows/ci-cd.yml) |
Pull request, push to main, manual |
Node from .nvmrc (default 22); npm ci with GitHub Packages auth; lint, test, build if present |
Only push/manual on main: build:vercel if defined, else build + SPA packaging, else placeholder; then vercel@60 deploy --prebuilt --prod. PR runs never deploy; main runs are never cancelled mid-deploy |
Secret VERCEL_TOKEN; variables VERCEL_ORG_ID, VERCEL_PROJECT_ID, VITE_PORTAL_API_URL. Design-system package grants each repo Actions read access |
portal-api · CI (ci.yml) |
Pull request, push to main |
Checks out portal-api, student-portal, control-plane; npm ci; docs:check (mirrored docs in sync); boots the API with throwaway demo passwords and runs scripts/smoke.sh (health, meta, login, wrong password → 401, portal call, logout → token revoked) |
None — deploy is manual (npm run deploy:vercel) because the build needs the sibling student-portal and the gitignored demo credentials |
Secret RAISD_READ_TOKEN (read-only PAT for the private sibling checkouts) |
portal-api-docs · Pages (pages.yml) |
Push to main, manual; PRs touching published paths run validation only |
Redocly lint of openapi.yaml; validate-design-system.py |
Assemble _site, verify required pages, inject site password, actions/deploy-pages |
Secrets DOCS_SITE_USER, DOCS_SITE_PASSWORD |
control-plane · OpenAPI, Sync diagrams note |
Changes to openapi.yaml, docs/** |
Validates the contract and design-system pages | None — canonical sources; copies are mirrored into portal-api-docs | — |
design-system · Package check and release |
Pull request, push to main, v* tags |
Docs-impact check, build, tests | Publishes @raisd-campus/design-system to GitHub Packages on tags |
GITHUB_TOKEN with packages: write |
| db-admin | — | npm run typecheck locally |
Manual npm run deploy (vercel deploy --prod, remote build) |
Vercel env only |
The portal workflow is one shared file: canonical copy control-plane/scripts/templates/portal-ci-cd.yml, copied verbatim into each portal repo. Deployment status across repos: npm run ci in control-plane.
Manual deploys
# Portal API (needs ../student-portal and ../control-plane/.credentials/demo-students.json)
cd portal-api
npm run deploy:vercel # build:vercel + vercel deploy --prebuilt --prod --scope <team>
API_URL=https://api.raisd.co npm run smoke
# DB admin
cd db-admin
npm run deploy # vercel deploy --prod --scope <team>
Neon database
| Property | Value |
|---|---|
| Provider | Neon (Vercel Marketplace native integration, attached to portal-api) |
| Engine · region | PostgreSQL 18 · aws-ap-southeast-1 (Singapore, next to Vercel sin1) |
| Database | neondb |
| Connections | Pooled endpoint for runtime (DATABASE_URL); direct endpoint for DDL / admin (DATABASE_URL_UNPOOLED); TLS with channel binding |
| Roles | neondb_owner — used by the Portal API. raisd_db_admin_reader — DB admin only: pg_read_all_data, default_transaction_read_only = on, no write grants |
| Migrations | None: on first use the API seeds when portal_meta is missing or schema_version ≠ contract version 4 (keeps student state + audit). Applicant Demo state uses applicant_portal_state / poc_applicant_sessions. Creates four reporting views (academic-status membership joins campus policy). npm run db:reset drops and reseeds records (sessions kept) |
| Data | Demo records for the four sample students (2,895 rows across 73 tables), their per-student portal state, and session rows — fictional data only, no CMS records |
Schema
Every collection of the student-portal PortalRecordGraph is one table, named in snake case
(studentProfiles → student_profiles): people, student profiles, programmes and versions,
modules, offerings, registrations, class schedules and sessions, attendance, assessments and results,
finance accounts, invoices, payments, funding, online forms, immigration, graduation, announcements, campus
services and more. The record jsonb is the source of truth; every top-level field is also a
read-only generated column so rows are readable in DB admin.
erDiagram
RECORD_TABLE {
text id PK "record id"
integer position "fixture order"
jsonb record "source of truth"
timestamptz updated_at "last write"
generated fields "one column per top-level field"
}
student_portal_state {
text student_profile_id PK
jsonb personal_profile
jsonb resume_profile
jsonb portfolio_profile
jsonb finance_payment_proofs
jsonb chat_store
timestamptz updated_at
}
portal_meta {
boolean id PK "single row"
bigint revision "+1 per record write"
timestamptz seeded_at
text_array collections
}
poc_sessions {
text token_hash PK "sha256 hex of the bearer token"
text scenario_id "Demo student scenario"
text student_profile_id "profile for the scenario"
timestamptz created_at "default now()"
timestamptz expires_at "created + 12 h (indexed)"
timestamptz revoked_at "set on logout, else null"
}
-- one per collection, e.g. invoices
create table if not exists invoices (
id text primary key,
position integer not null,
record jsonb not null,
updated_at timestamptz not null default now(),
finance_account_id text generated always as (record->>'financeAccountId') stored,
total_minor numeric generated always as ((record->>'totalMinor')::numeric) stored,
status text generated always as (record->>'status') stored
-- … one column per top-level field
);
create table if not exists poc_sessions (
token_hash text primary key,
scenario_id text not null,
student_profile_id text not null,
created_at timestamptz not null default now(),
expires_at timestamptz not null,
revoked_at timestamptz
);
create index if not exists poc_sessions_expires_at on poc_sessions (expires_at);
- The raw token is never stored — only its SHA-256 hash, so a database read cannot replay sessions.
- A session is valid when
revoked_at is null and expires_at > now(). scenario_idis one ofmid-programme,first-semester-registration,new-student-pass,graduation-ready.- Expired and revoked rows are kept (no purge job yet).
student_portal_stateholds engine state kept outside the record graph (edited profile, resume, portfolio, payment proofs, chat), one row per Demo student.- Fixture dates are relative to
seeded_at, so the Demo drifts as days pass; reseed withnpm run db:reset.
Flows
Sign-in
sequenceDiagram
autonumber
actor S as Student
participant SP as Student portal (Vercel static)
participant API as Portal API (Vercel function)
participant DB as Neon poc_sessions
S->>SP: Student number + password
SP->>API: POST /v1/auth/login {scenarioId, password}
API->>API: Validate body, constant-time password check
alt wrong password
API-->>SP: 401 "Student and password do not match."
else ok
API->>DB: create table if not exists (first use)
API->>DB: insert token_hash, scenario, profile, expires_at
API-->>SP: 200 {accessToken, expiresIn 43200, scenarioId, studentProfileId}
SP->>SP: Keep bearer token for the session
end
Portal call
sequenceDiagram
autonumber
participant SP as Student portal
participant API as Portal API instance
participant DB as Neon
participant M as MockPortalApi (per request)
SP->>API: POST /v1/portal/submitOnlineForm {args} + Bearer
API->>DB: poc_sessions lookup by sha256(token)
alt no row
API-->>SP: 401 Session expired or missing
else session found
API->>DB: portal_meta revision + student_portal_state row
opt cache is not at seeded_at + revision
API->>DB: read all record tables (one read-only snapshot)
end
API->>M: fresh engine over the records for scenario_id
M-->>API: result (+ changed records / student state)
opt method wrote
API->>DB: begin · lock portal_meta · revision unchanged?
API->>DB: upsert / delete changed rows · revision + 1 · upsert student state · commit
Note over API,DB: revision moved → reload and rerun (3 tries, then 409)
end
API-->>SP: 200 {result, cms: {acknowledged: true, mode: "stub"}}
end
Any instance accepts any token and sees the same records because both live in Neon. Edits persist across instances and cold starts, and everyone signed in as the same Demo student sees them. Only the 55 PortalApi methods are callable (404 otherwise); database errors return 503.
Switch student and sign out
sequenceDiagram
participant SP as Portal
participant API as Portal API
participant DB as Neon
SP->>API: POST /v1/auth/scenario {scenarioId} + Bearer
API->>DB: update scenario_id, student_profile_id
API-->>SP: 200 {scenarioId, studentProfileId}
SP->>API: POST /v1/auth/logout + Bearer
API->>DB: update revoked_at = now()
API-->>SP: 200 {ok: true}
Portal deploy
sequenceDiagram
actor Dev as Developer
participant GH as GitHub (portal repo)
participant GA as GitHub Actions ci-cd.yml
participant PK as GitHub Packages
participant V as Vercel
Dev->>GH: merge / push to main
GH->>GA: push event
GA->>PK: npm ci (@raisd-campus/design-system via GITHUB_TOKEN)
GA->>GA: lint · test · build
GA->>GA: build .vercel/output (VITE_PORTAL_API_URL baked in)
GA->>V: vercel deploy --prebuilt --prod (VERCEL_TOKEN)
V-->>GA: deployment URL, alias raisd-<repo>.vercel.app
Config and secrets
| Value | Lives in | Used by |
|---|---|---|
| Neon owner connection strings | Vercel env on portal-api (integration-managed) | Portal API runtime |
| Reader connection string, DB admin login | Vercel env on db-admin | DB admin |
| Demo student passwords | Gitignored control-plane/.credentials/; inlined into the API bundle at build | POST /v1/auth/login |
VERCEL_TOKEN | GitHub secret on each portal repo | Portal deploy job |
RAISD_READ_TOKEN | GitHub secret on portal-api | Sibling checkouts in CI |
| Docs site login | GitHub secrets on portal-api-docs | Pages assembly |
- Never commit
.env, Neon connection strings, Vercel tokens, or demo passwords. Local copies sit only in the gitignoredcontrol-plane/.credentials/. - API logs redact
Authorizationand the login password. - Rotate the Neon role (and redeploy) if a connection string leaks; recreate the reader with
npm run db:create-readerin db-admin. - PoC databases may be wiped; do not store real student PII.
Limitations
- No CMS: every write is Demo;
cms.modeisstubuntil CAP-53 connects the Cyberjaya CMS. - Mock engine: records are in Neon but still run through the student-portal
MockPortalApirules; student state (profile, portfolio, proofs, chat) is last-write-wins per student. - Date drift: fixture dates are relative to the seed time; reseed with
npm run db:reset. - No file store: uploads (evidence, assignments, payment proofs) are metadata only.
- Shared Demo logins: four sample students, passwords held by the team.
- Free tier: Neon 0.5 GB storage, 50 CU-hours / month, scale-to-zero (first request after idle is slower).
- Session rows accumulate: no cleanup job for expired / revoked rows.
Why Neon on Vercel
Vercel’s first-party Vercel Postgres is discontinued; databases come from Vercel Marketplace.
Neon is the simplest free option: native integration, $0 start, scale-to-zero, branching.
Prefer Supabase only if a PoC needs bundled Auth / Storage / Realtime — Raisd’s shape
(SPA → Portal API → Postgres) does not.
Graduation path: keep the Portal API contract; move durable data to SDD-12 Postgres on UltaHost
(or the CAP-53 CMS adapter). Swap DATABASE_URL / CMS adapter — do not teach the SPA a second data plane.
Out of scope
- Replacing UltaHost / k3s stages or hostnames (
apply.raisd.co,api.raisd.co, …). - Moving the existing Cyberjaya CMS onto Neon or Vercel.
- Staff Live progression APIs (still deferred —
GET /v1/meta/staff-progression). - Using free-tier Neon as production capacity planning.