integrity userapi - XibalbaTechSol/integrity-core GitHub Wiki
title: integrity-userapi created: 2026-07-09 updated: 2026-09-15 type: entity tags: [infrastructure, identity] confidence: high source_files:
- integrity-userapi/app/main.py
- integrity-userapi/app/db.py
- integrity-userapi/app/deps.py
- integrity-userapi/app/security.py
- integrity-userapi/app/login_limiter.py
- integrity-userapi/app/oracle_client.py
- integrity-userapi/app/schemas.py
- integrity-userapi/app/config.py
- integrity-userapi/migrations/0001_init.sql
- integrity-userapi/migrations/0002_jwt_revocation.sql
- integrity-userapi/migrations/0003_user_wallets.sql
- integrity-userapi/tests/conftest.py
- integrity-dashboard/demo/src/integrity_demo/userapi_bridge.py
- docker-compose.yml
FastAPI + Postgres service owning user-facing account data only โ
accounts, developer API keys, which agent DIDs a human user has claimed as
theirs, and demo-run request history. It is the strictly non-chain half of
the backend split (see Interface Contract
ยง6.10, ยง13): it never imports web3/alloy-equivalent tooling and never
reads a deployments.*.json file. app/oracle_client.py is the only
place this package talks to another service โ a plain httpx HTTP client
fanning out to integrity-oracle, never a direct
chain RPC.
Table of contents
- Endpoints
- Custodial app-wallet (added 2026-07-25)
- The oracle tri-state
- Four gaps closed 2026-07-15 (all four of this package's PRODUCTIONGAPS.md ยง6 findings)
- Tests and Postgres wiring
- CORS (added 2026-07-09, real gap)
Endpoints
POST /auth/register, POST /auth/login, GET /me โ JWT bearer auth
(app/security.py: argon2 password hashing via passlib โ deliberately
argon2, not bcrypt, since passlib[bcrypt] 1.7.4 breaks against
bcrypt>=4.1's removed __about__.__version__). POST /api-keys /
GET /api-keys / DELETE /api-keys/{id} โ developer API keys are random
high-entropy tokens (uak_<43 b64url chars>), returned once, persisted only
as a sha256 hash (fast deterministic hash is correct here since the input is
already 256 bits of entropy, unlike a human password). GET /me/agents /
POST /me/agents โ user_agents stores an ownership pointer only
(user_id, agent_did); every GET fans out live to
integrity-oracle's GET /v1/agent/{id} per owned DID rather than caching
agent state locally. POST /demo/run / GET /demo/runs โ records that a
run was requested, starting at status='pending'; this service never
orchestrates a demo or fabricates a 'completed' result (that's
integrity-dashboard's demo/ engine, a separate process).
Custodial app-wallet (added 2026-07-25)
GET /me/wallet / POST /me/wallet/transfer back a per-user custodial $ITK
ledger โ the honest counterpart to the dashboard's self-custodial (MetaMask)
path. migrations/0003_user_wallets.sql adds user_wallets(user_id PK, app_wallet_address UNIQUE, itk_balance_wei NUMERIC(78,0) CHECK >= 0) โ a
NUMERIC(78,0) covers the full uint256 range exactly (no f64 loss). GET /me/wallet is get-or-create (first call mints a deterministic app-wallet address
- a dev faucet balance);
POST /me/wallet/transferis a real transactional debit/credit (SELECT โฆ FOR UPDATE), not a mocked number โ a genuine custodial account whose balance is an authoritative internal ledger. A production deployment would back the address with a real custodied keypair + on-chain settlement; the ledger semantics are already real. Consumed by the dashboard'sTokenWallet.
The oracle tri-state
oracle_client.fetch_agent never raises and never fabricates data. It
returns AgentLookupResult(live_data, error) in exactly one of three
states: live data (error=None), not found (live_data=None, error="agent not found on oracle" on a 404), or oracle unreachable
(live_data=None, error="oracle unreachable: ..." on any httpx.HTTPError
โ connection refused, timeout, DNS failure). A single bad lookup in
GET /me/agents's per-agent loop never crashes the whole response; the
caller always gets an honest state, never a silently-empty
live_data: null standing in for "couldn't check."
Four gaps closed 2026-07-15 (all four of this package's PRODUCTION_GAPS.md ยง6 findings)
- API keys now actually authenticate requests.
app/deps.py::get_current_user_idaccepts either a JWT bearer token or anX-API-Keyheader carrying a rawuak_...key (sha256-hashed, looked up againstapi_keys.key_hash WHERE revoked_at IS NULL) โ the gap this section used to describe ("no code path that uses a raw key at all") is closed. Deliberate exception: minting (POST /api-keys) and revoking (DELETE /api-keys/{id}) a key stay JWT-only (a newget_current_tokendependency, notget_current_user_id) โ an API key that could mint further keys would let one leaked long-lived credential outlive its own revocation. - JWTs are now revocable.
create_access_tokenstamps a per-tokenjti(uuid4);decode_access_tokenreturnsDecodedToken(user_id, jti, expires_at). Newmigrations/0002_jwt_revocation.sqladdsrevoked_tokens(jti PK, user_id, revoked_at, expires_at). NewPOST /auth/logoutinserts the presented token'sjtithere โ and, since it already has the transaction open, opportunistically sweeps already-expiredrevoked_tokensrows first, so the table self-prunes without a separate cron job (a revoked token whoseexphas already passed could never be replayed anyway). - Login is now rate-limited. New
app/login_limiter.py(LoginRateLimiter) mirrorsbcc_middleware/app/circuit_breaker.py's in-memory per-key-counter-plus-timed-lockout shape (same accepted single-process state tradeoff), keyed on the lowercased login email rather than an agent DID, with deliberately looser defaults (failure_threshold=5/lockout=300svs. bcc's3/900s) since a login form has a much higher legitimate-typo rate than a signed agent commitment.POST /auth/login429s (with aRetry-Afterheader) once tripped โ even against the correct password. demo_runshas a real completion path. NewPATCH /demo/runs/{id}(DemoRunUpdateRequest) lets the owning user transition their run throughrunningโcompleted/failed, stampingfinished_atonly on a terminal status and storing a realresult_summaryJSONB payload (asyncpg now round-tripsjsonbas plain dicts everywhere via a codec registered inapp/db.py::create_pool, previously unregistered since nothing had ever written non-null JSONB before this). Newintegrity-dashboard/demo/src/integrity_demo/userapi_bridge.pycalls this endpoint from the scenario engine itself โmain()reportsrunningat start andcompleted/failed(with a real summary) at the end, entirely opt-in via three env vars (USERAPI_URL/USERAPI_TOKEN/USERAPI_RUN_ID) an operator sets when they want a specificmake demoinvocation tied back to ademo_runsrow created beforehand. Still genuinely out of scope, not fixed: nothing inintegrity-dashboard's dashboard UI creates ademo_runsrow or launches this CLI process โmake demoremains an operator-run script against live Base Sepolia using a funder private key, not something the frontend can trigger yet.
Tests and Postgres wiring
51 pytest tests (up from 33 โ the 2026-07-15 additions above), plus 6
new tests in integrity-dashboard/demo/tests/test_userapi_bridge.py (against a
real local ThreadingHTTPServer, same pattern as this package's own
_FakeOracleServer โ no-op when the three env vars are unset,
bearer-vs-X-API-Key header selection, HTTP/connection errors swallowed
not raised). All against a real Postgres โ never sqlite, never a
mocked DB. docker-compose.yml has a dedicated userapi-postgres
service (postgres:16-alpine, integrity/integrity_dev_only, db
integrity_userapi, host port 5435 โ its own instance, deliberately
never sharing integrity-oracle's postgres service on 5432 or its ad hoc
5434 e2e-test convention, since two separate Postgres instances per trust
domain is the point of the ยง6.10 split) and a userapi app service
(integrity-userapi/Dockerfile, uv-based like bcc_middleware's, port
8090). tests/conftest.py auto-creates a dedicated
integrity_userapi_test database on the same server, forces DATABASE_URL
and ORACLE_URL before app.config is ever imported (so a dev/CI shell's
ambient ORACLE_URL โ a documented shared env var โ can never leak into
the "oracle unreachable" test and make it flaky-green), truncates all
tables between tests, and drives the real FastAPI startup/shutdown lifespan
via asgi-lifespan's LifespanManager so app/db.py's real
run_migrations runs on every test's setup โ not a hand-rolled schema
substitute. The GET /me/agents tri-state is tested against a real local
ThreadingHTTPServer standing in for the oracle (real socket, real HTTP,
real JSON parsing through fetch_agent), never a mock of
oracle_client's internals. Unlike integrity-oracle's opt-in/env-gated
e2e test, this suite has no skip path โ it fails hard at collection if
userapi-postgres isn't reachable, matching the package's stated
completion gate.
Verified manually (not just via pytest): docker compose build userapi
succeeds; docker compose up -d --no-deps userapi boots against
userapi-postgres over the compose network, GET /health returns
{"status":"online","service":"integrity-userapi"}, and
schema_migrations shows 0001_init.sql applied.
CORS (added 2026-07-09, real gap)
app/main.py had no CORS policy before this pass โ a real, hard-blocking
gap for the one browser caller this service exists to serve
(integrity-dashboard's dashboard, a different origin by
construction). Fixed with CORSMiddleware (allow_origins=["*"],
allow_credentials=False โ every authenticated call carries a bearer JWT,
never a cookie, so this is safe). Verified: all 51 pytest tests unaffected
(CORS is a browser-enforced concern, invisible server-side); a real
cross-origin call from integrity-dashboard now succeeds
(integrity-dashboard/e2e/auth.spec.ts, against a real uvicorn instance
integrity-dashboard/e2e/global-setup.ts now boots on its own ephemeral
Postgres database, port 8093 for E2E โ see
Interface Contract ยง14).
Related: integrity-oracle (the only service this one talks to), DID, Interface Contract ยง13-ยง14, integrity-dashboard (the one real caller of this service's auth endpoints).