# SPEC — IDRS API server (Python FastAPI) ## §G — Goal FastAPI + SQLite server mirroring idrs-api live endpoints — `GET /api/getTokenInfo` + `GET /api/version/android` + icon files. Admin panel (sqladmin, login-protected) to enter/edit data → served as JSON API. Seed from existing `tokenList.json` + `versionAndroid.json` on first run. ## §C — Constraints - Python 3, FastAPI + SQLAlchemy + sqlite (WAL) + `sqladmin` admin panel (Bootstrap UI, built-in login) — venv `./venv`, deps: `fastapi`, `uvicorn`, `sqlalchemy`, `sqladmin`, `jinja2`, `python-multipart` (upload); install in venv, ⊥ global - endpoints match live paths verbatim: `/api/getTokenInfo`, `/api/version/android`, `/img/`; admin panel at `/admin` (login-gated) - admin CRUD via `sqladmin` ModelAdmin: token (10 fields: idfToken, position, typeBlockchain, name, symbol, contractAddr, decimals, iconUrl, filename, is_live) + version record (idVersion, appName, acceptableVersion, latestVersion); ⊖ hand-rolled admin.html + old CRUD routes - auth: single admin via env `ADMIN_USERNAME` + `ADMIN_PASSWORD`; session-signing secret auto-generated on first run + persisted locally (survives restart); creds unset → fail closed (∀ login refused + warning logged, ⊥ default/empty password) - icon upload PNG → stored under `token/` subdir → served `/img/token/`; upload sets `iconUrl` = `{IDRS_PUBLIC_URL}/img/token/`; `iconUrl` otherwise free-form (external URL allowed) - cleaned types OK: `decimals` int, `is_live` bool — byte drift from live API (string-typed there) accepted; shape + field names kept verbatim - seed once on first run (empty DB) from repo-root `tokenList.json` (23 tokens) + `versionAndroid.json`; ⊥ reseed if DB non-empty - `idfToken` unique; response ordered `position` ASC - version rule: `acceptableVersion` ≤ `latestVersion` ! enforced (else reject) - out of scope: multi-user table, roles, 2FA, password reset, on-chain calls, audit trail, change history - data survival: existing `idrs.db` file + schema kept (migration-free); SQLAlchemy reads the same file - run: `./venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000` + `run.sh` wrapper ## §I — Interfaces api: GET `/api/getTokenInfo` → 200 `{tokenList:[{idfToken:int, position:int, typeBlockchain:string, name:string, symbol:string, contractAddr:string, decimals:int, iconUrl:string, filename:string, is_live:bool}]}` (position ASC) api: GET `/api/version/android` → 200 `{idVersion:int, appName:string, acceptableVersion:int, latestVersion:int}` api: GET `/img/` → 200 image/png | 404 unknown web: GET `/admin` → sqladmin panel (login-gated; login page when unauthenticated) web: GET `/admin/login` → login form; POST → session cookie on success web: GET `/admin/logout` → end session web: POST `/admin/token//icon` → upload PNG (multipart) → sets iconUrl `/img/` (custom panel view/action) env: `ADMIN_USERNAME`, `ADMIN_PASSWORD` (login); `IDRS_SESSION_SECRET` (session signing, auto-gen + persist on first run) file: `tokenList.json`, `versionAndroid.json` → seed source (repo root); read-only after seed ## §V — Invariants V1: ∀ token → `idfToken` unique; `position` unique; response ordered `position` ASC V2: response shape fixed: `getTokenInfo` = `{tokenList:[...]}`, `version/android` = flat object; field names verbatim from live API V3: `decimals` int, `is_live` bool in DB + serialized (cleaned types; drift from live string-typing accepted) V4: `acceptableVersion` ≤ `latestVersion`; update violating rule → reject V5: seed runs once: empty DB → import `tokenList.json` + `versionAndroid.json`; DB non-empty → ⊥ reseed V6: ∀ upload → PNG, filename sanitized (basename, safe chars), written under `token/` subdir of icons dir; duplicate filename → overwrite (idempotent) V7: ∀ token → `iconUrl` = `{IDRS_PUBLIC_URL}/img/token/` after upload; token without upload → keep entered URL (external ok) V8: token delete → row removed; response excludes it on next GET V9: ∀ admin view → auth check before render; unauthenticated → redirect `/admin/login`; wrong creds → reject; logout ends session V10: `ADMIN_USERNAME`/`ADMIN_PASSWORD` unset → ∀ login refused + warning logged; ⊥ default/empty credential V11: public API endpoints (`/api/getTokenInfo`, `/api/version/android`, `/img/...`) ⊥ auth; response shapes unchanged (V2,V3) V12: same `idrs.db` file + schema used by raw-sqlite AND SQLAlchemy (no migration); empty DB → seed still runs V13: version table single row (`id=1`) maintained — panel ⊥ creates duplicates V14: ∀ upload → PNG verified by magic bytes `\x89PNG\r\n\x1a\n` (⊥ trust `content-type` header), extension forced `.png`, size ≤ 512KB; `/img/` served with `image/png` (⊥ `guess_type`) V15: token edit/create form upload → `on_model_change` sets `filename` + `iconUrl={IDRS_PUBLIC_URL}/img/token/` (base env var, default `https://idrs.databisnis.id`, trailing `/` stripped) before persist; no new file → keep existing (⊥ clear) V16: `idfToken` auto-increment only — not in form, never written from form data (⊥ injectable); `typeBlockchain` fixed `"Vexanium"` — not in form, always forced on write (⊥ injectable); `position` editable, default = next idfToken value ## §T — Tasks id|status|task|cites T1|x|venv + deps (`fastapi`, `uvicorn`, `jinja2`, `python-multipart`)|§C T2|x|sqlite schema + WAL + seed from `tokenList.json`/`versionAndroid.json`|V5 T3|x|GET `/api/getTokenInfo` (position ASC, cleaned types)|V1,V2,V3 T4|x|GET `/api/version/android`|V2,V3 T5|x|icon upload + `/img/` serving|V6,V7 T6|x|`/admin` token list + CRUD|V1,V3,V8 T7|x|`/admin` version edit + rule check|V4 T8|x|`run.sh` + README quickstart (seed, run, admin)|§C T9|x|deps `sqlalchemy` + `sqladmin`; SQLAlchemy models over existing `idrs.db`|V12 T10|x|auth backend (env creds, fail-closed) + persisted session secret|V9,V10 T11|x|ModelAdmin Token + Version (single-row guard)|V1,V4,V13 T12|x|port seed to SQLAlchemy; public endpoints keep shapes on same DB|V11,V12 T13|x|icon upload in panel (custom view/action); drop admin.html + old CRUD routes|V6,V7 T14|x|`run.sh`/`.env` + README update (creds, secret, login)|§C,V10 T15|x|custom WTForms form on TokenAdmin (FileField `icon`) + `on_model_change` file handling|V15 T16|x|remove IconUploadView BaseView + upload.html + `/admin/upload`|V9 T17|x|PNG magic-byte + extension + size validation; force `image/png` on `/img`|V14 T18|x|`IDRS_PUBLIC_URL` env base for upload iconUrl (default domain); one-time backfill of relative `/img/...` iconUrl|V15 T19|x|form: hide `idfToken` (auto) + `typeBlockchain` (fixed Vexanium); `position` default = next id, editable; empty position → auto-default|V16 T20|x|Dockerfile (python:3.12-slim, uvicorn single worker, non-root) + .dockerignore; DB (`/app/data`) and icons (`/app/app/static/icons`) volumes persist|§C T21|x|`tokenList.json`: replace seed iconUrl domain `idrs.kriptoteknologi.io` → `idrs.databisnis.id` (14 rows; existing DBs need manual update)|V5 T22|x|icon upload → `token/` subdir + `/img/token/` route (flat route removed); migrate `ayam-logo.png` into `token/`, backfill token1 iconUrl|V15 ## §B — Bug log id|date|cause|fix B1|2026-08-16|seed `tokenList.json`: token CHIP `"filename":null` → `t.get("filename","")` returns None (key exists) → NOT NULL constraint failed|seed null-coalesces `t.get("filename") or ""`, `t.get("iconUrl") or ""` (V5) B2|2026-08-16|icon upload checks only client `content-type` header, saves any filename, `/img` serves mime via `guess_type` → uploaded `x.html` served as `text/html` on API origin (stored XSS)|V14: magic-bytes PNG check + `.png` extension + size cap + forced `image/png` content-type on `/img`