Files
idrs-api/SPEC.md
T

7.7 KiB

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/<filename>; 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/<filename>; upload sets iconUrl = {IDRS_PUBLIC_URL}/img/token/<filename>; 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/<filename> → 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/<id>/icon → upload PNG (multipart) → sets iconUrl /img/<filename> (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/<filename> 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/<filename> served with image/png (⊥ guess_type) V15: token edit/create form upload → on_model_change sets filename + iconUrl={IDRS_PUBLIC_URL}/img/token/<filename> (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/<filename> 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/<filename> 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