pddikti
by andikadevs
README.md
# PDDikti API
Bun + Hono API providing structured access to Indonesia higher-education data (PDDikti):
universities, study programs, lecturers, students, and aggregate statistics.
This service is a typed proxy in front of a private upstream PDDikti data source, with
auto-generated OpenAPI docs.
## Requirements
- [Bun](https://bun.com) ≥ 1.3
## Setup
```sh
bun install
cp .env.example .env # set UPSTREAM_BASE_URL; supply Cloudflare creds at runtime (see below)
```
## Run
```sh
bun run dev # hot reload
bun run start # start server
```
Open [http://localhost:3000](http://localhost:3000).
## Docker setup
Run as **three containers** — the API + a **FlareSolverr** sidecar (hands-off Cloudflare
clearance) + **Redis** (response cache) — via the provided `docker-compose.yml`:
```sh
docker compose up -d # build + start api (:3000) + flaresolverr
docker compose logs -f api # follow API logs
docker compose down # stop
```
What it wires:
- **`api`** — built from the multi-stage `Dockerfile` (`oven/bun:1.3-alpine`, frozen-lockfile
deps-only install). Reads `.env`; compose also sets `SOLVER_ENABLED=true`,
`SOLVER_URL=http://flaresolverr:8191/v1`, and `CONFIG_STORE_PATH=/data/runtime-config.json`.
- **`flaresolverr`** — `ghcr.io/flaresolverr/flaresolverr`, **internal only** (not published to
the host); its headless browser solves the Cloudflare challenge on a `403`.
- **`redis`** — `redis:7-alpine` (password + AOF persistence), internal only; the API's cache.
- **`config-data`** / **`redis-data`** volumes — persist the solved `cf_clearance` and the
cache across restarts.
Only the API is reached (via the reverse proxy); FlareSolverr + Redis stay internal. For a
public-facing
setup put a TLS-terminating reverse proxy in front, and set `PUBLIC_BASE_URL`, `API_KEY`,
and `CONFIG_PASSWORD` accordingly.
Single image, no solver (refresh clearance manually via `/#/config`):
```sh
docker build -t pddikti .
docker run -p 3000:3000 --env-file .env pddikti
```
## Configuration
All config is read once from the environment via `src/config.ts` (the only place that touches
`process.env`). Bun auto-loads `.env`. Key variables:
| Variable | Default | Purpose |
| ----------------------------------- | ------------------------- | -------------------------------------------------------------------- |
| `PORT` | `3000` | Listen port |
| `PUBLIC_BASE_URL` | `http://localhost:3000` | Absolute base for docs links +`secure` cookies |
| `UPSTREAM_BASE_URL` | — | PDDikti API base (`https://pddikti.kemdiktisaintek.go.id/api`) |
| `UPSTREAM_TIMEOUT_MS` | `15000` | Per-request upstream timeout |
| `API_KEY` | `` (off) | Gate`/api/*` (`X-API-Key` or SPA session cookie); empty = public |
| `SESSION_TTL_S` | `86400` | Session/config cookie lifetime |
| `CONFIG_PASSWORD` | `` (off) | Enables the`/#/config` page; empty = disabled (404) |
| `CONFIG_STORE_PATH` | `./runtime-config.json` | Where runtime overrides + solved cookie persist |
| `SOLVER_ENABLED` | `false` | Auto-solve Cloudflare on`403` via FlareSolverr |
| `SOLVER_URL` | — | FlareSolverr`/v1` endpoint |
| `CACHE_ENABLED` / `CACHE_TTL_S` | `false` / `600` | Optional Redis response cache |
| `CREDIT_LINE` | `Data © PDDikti` | Attribution injected into every JSON response |
See `.env.example` for the full list.
## Web UI
- `GET /` — flat React **PT profile explorer**: search a university, pick one, and see its
profile, stats, cost, study duration, study programs, **all lecturers**, **student
search**, facilities, and name history. The selected university is carried in the URL hash
(`#/pt/<id>`). Bundled natively by a single `Bun.build` (JS + Tailwind v4 CSS via
`bun-plugin-tailwind`), no Vite. Client code is organized into `components/` (UI + views),
`hooks/` (`use-endpoint`, `use-selected-pt`, `use-debounced-value`), and `lib/`
(`constants`, `format`, `theme`). Requires a configured upstream (below) to show live data.
## Docs
- Overview: `GET /api/`
- Swagger UI: `/api/docs`
- ReDoc: `/api/redoc`
- OpenAPI spec: `/api/openapi.json` (live)
- MCP tools: `/#/mcp` (readable view) · `/mcp/tools` (JSON catalog — the MCP analogue of
the OpenAPI spec)
- Static spec: [`openapi.json`](./openapi.json) in the repo, for offline agent
reference. Regenerate after route changes with `bun run openapi:export`
(set `PUBLIC_BASE_URL` to stamp the `servers` URL, e.g.
`PUBLIC_BASE_URL=https://<host> bun run openapi:export`).
## MCP (AI agent access)
The same data is exposed to AI agents over the **Model Context Protocol**. Tools are
consolidated and enum-driven (~10): `search_pddikti`, `get_university`,
`get_university_study_programs`, `get_university_lecturers`, `get_study_program`,
`get_study_program_lecturers`, `get_lecturer`, `get_student`, `get_statistics`,
`list_study_programs_by_field`. Each routes through the same proxy egress, cache, and
credit envelope as the REST API.
Two transports:
- **stdio** — `bun run mcp` (runs `src/mcp/stdio.ts`). For local clients.
- **Streamable HTTP** — `POST /mcp` on the running server. For remote/hosted clients.
Always public (no API key) — even when `/api/*` is gated by `API_KEY` — so browser
clients like Claude.ai, which can't send a static `X-API-Key`, can connect.
### Claude Code (CLI)
Local (stdio) — from the repo root, env comes from `.env`:
```sh
claude mcp add pddikti -- bun run /abs/path/to/pddikti/src/mcp/stdio.ts
```
Or commit a project-scoped `.mcp.json` (already included — fill the env):
```json
{
"mcpServers": {
"pddikti": {
"command": "bun",
"args": ["run", "./src/mcp/stdio.ts"],
"env": {
"UPSTREAM_BASE_URL": "https://pddikti.kemdiktisaintek.go.id/api",
"CONFIG_STORE_PATH": "/abs/path/to/pddikti/runtime-config.json"
}
}
}
}
```
Pointing `CONFIG_STORE_PATH` at the same `runtime-config.json` the config page writes lets
the stdio server reuse that Cloudflare clearance (or set `SOLVER_ENABLED`/`SOLVER_URL` to
auto-resolve). See *Upstream config* below.
Remote (HTTP) against a running instance:
```sh
claude mcp add --transport http pddikti https://<host>/mcp
```
### Claude Desktop
Edit `claude_desktop_config.json` (Settings → Developer → Edit Config) — same shape as
`.mcp.json` above under `mcpServers`. Restart the app; tools appear under the 🔌 menu.
### Claude.ai (web)
Settings → **Connectors** → *Add custom connector* → URL `https://<host>/mcp`. The web UI
cannot send a static `X-API-Key`/Basic-auth header, so `/mcp` is intentionally public (no
key) regardless of `API_KEY` — the web connector works out of the box. Only the `/api/*`
REST surface is key-gated.
### Quick test
```sh
bun run mcp:inspect # MCP Inspector against the stdio server
# Or hit the HTTP endpoint directly (initialize handshake):
curl -s https://<host>/mcp -X POST \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}'
```
## Upstream config
This API proxies the official PDDikti backend, which lives at `.../api` on the main domain
and is guarded by **Cloudflare bot management**. Only the base URL goes in `.env`:
```dotenv
UPSTREAM_BASE_URL=https://pddikti.kemdiktisaintek.go.id/api
```
Cloudflare needs a `cf_clearance` cookie + the **exact** `User-Agent` it was issued to (a
mismatched/absent UA → `403`). You provide these **at runtime**, not in `.env`, two ways:
- **Manually via the config page** — set `CONFIG_PASSWORD`, open the **Konfigurasi** view
(`/#/config`, a React page backed by the `/config/*` JSON API), paste the
`cf_clearance` + User-Agent (or a full DevTools **Copy as cURL**, auto-parsed). Saved to
`CONFIG_STORE_PATH`, applied live, persists across restarts. Refresh when it expires.
- **Automatically via the solver** — see *Auto-solver* below; a `403` self-heals with no
manual step.
(An optional `UPSTREAM_HEADERS` JSON env map is still accepted as a fallback baseline, but is
overridden by the runtime config and normally left unset.)
The upstream wraps payloads as `{status,data}` and returns opaque base64url ids (with `=`
padding, sent raw in the path) — both handled in `lib/upstream.ts` / `lib/encoding.ts`.
Without `UPSTREAM_BASE_URL`, `/api/*` returns a clean `503` envelope (no data).
### Auto-solver (optional, hands-off clearance)
`cf_clearance` expires, so instead of refreshing it by hand you can let the app do it —
server-deployable via **FlareSolverr** (a headless-browser challenge solver that runs in its
own container). Set `SOLVER_ENABLED=true` + `SOLVER_URL` (the FlareSolverr `/v1` endpoint).
On a `403`, the proxy calls FlareSolverr to clear the challenge, saves the fresh
`cf_clearance` + User-Agent (to `CONFIG_STORE_PATH`), and retries the request once. Left off,
refresh manually via the `/#/config` page.
Run both with the provided `docker-compose.yml`:
```sh
docker compose up -d # starts the API + a flaresolverr sidecar
```
The browser lives inside FlareSolverr, so this works on a headless server (no display).
## Architecture
- **Bun + Hono** (`OpenAPIHono`), exported as `export default { fetch, port }` — no
`Bun.serve`, Vite, or Express.
- **Single egress**: every upstream call goes through `lib/upstream.ts` (auth headers,
timeout, `{status,data}` unwrap, error mapping, `403` auto-refresh), with an optional Redis
cache (`lib/cache.ts`). Routes never `fetch` directly.
- **Zod-first**: one schema drives both request validation and OpenAPI generation; strict
TypeScript, no `any` (upstream payloads are opaque `z.unknown()`).
- **Two frontends, one core**: the REST `/api/*` routes and the in-process MCP server (`/mcp`)
both call `lib/` directly — identical egress, cache, and credit envelope, no second hop.
- **Native React SPA**: bundled by a single `Bun.build` (+ Tailwind v4 CSS-first), served by
Hono; `components/` + `hooks/` + `lib/` layout, one shared sticky `Header`.
- **Definition of done**: `bun run typecheck` (clean) + `bun test` (green), no network in tests.
Full conventions live in [CLAUDE.md](./CLAUDE.md).
## Scripts
| Script | Purpose |
| ----------------------- | --------------------------------------------- |
| `bun run dev` | Hot-reloading dev server |
| `bun run start` | Run server |
| `bun run mcp` | Run the MCP server over stdio |
| `bun run mcp:dev` | MCP server (stdio) with hot reload |
| `bun run mcp:inspect` | Launch MCP Inspector against the stdio server |
| `bun test` | Run tests |
| `bun run typecheck` | `tsc --noEmit` |
## Project conventions
See [CLAUDE.md](./CLAUDE.md).
## License
[MIT](./LICENSE) — open source, free to use, copy, modify, and distribute.
## Disclaimer
**For educational and learning purposes only.** This is a personal, non-commercial study
project.
- **Not affiliated** with or endorsed by PDDikti, Kemdiktisaintek, or Cloudflare. All upstream
data is © PDDikti; names belong to their respective owners.
- **Provided "as is"**, with no warranty of accuracy, availability, or fitness (see the MIT
terms).
- **No liability for misuse.** The authors accept no responsibility for how this software is
used or for any damages arising from it — you use it entirely at your own risk.
- **Your responsibility.** You must comply with all applicable laws and the terms of service of
any system it touches (including the upstream's terms and its anti-bot/rate-limit
protections). Do not use it to overload, disrupt, mass-scrape, or abuse any service, or to
handle personal data unlawfully.
See [LICENSE](./LICENSE) for the full text.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues