IndieK local API
The HTTP backend of the IndieK Desktop App (FOSS). It opens local archive folders
(typically git repositories) in place, verifies them with the
indiek library, and exposes their current state
and the archive write operations to the IndieK web UI (custom-graph-explorer).
web UI (browser) ──HTTP──► indiek_api (this repo, 127.0.0.1 only) ──► indiek library ──► archive folder on disk
(PyPI: indiek) (git repo, edited in place)
The server always binds to 127.0.0.1: it signs archive writes with your key,
so it must never be reachable from the network.
1. Prerequisites
| Tool | Notes |
|---|---|
| Python ≥ 3.14 | pinned in .python-version; uv installs it if missing |
uv |
the only supported package/venv manager (no pip, no poetry) |
indiek |
installed from PyPI (version pinned in uv.lock); nothing else to check out. To run against an unreleased local checkout of the library, overlay it per command without touching pyproject.toml: uv run --with-editable ../public_archive pytest (any path to the indiek source works). |
fastAPI/ is its own git repository.
2. Setup
cd fastAPI
uv sync # creates .venv/ and installs runtime + dev dependencies from uv.lock
Dependency changes: uv add <pkg> / uv add --dev <pkg> / uv remove <pkg> (updates
pyproject.toml and uv.lock; commit both). Never edit uv.lock by hand.
3. Daily commands
| Command | What it does |
|---|---|
uv run python -m indiek_api |
Run the server on 127.0.0.1, random port; prints the one-time launch URL and opens it in the browser (--no-browser to only print it). Docs at /docs, schema at /openapi.json |
uv run pytest |
Full test suite |
uv run pytest tests/test_api.py |
One file |
uv run pytest tests/test_api.py::test_name |
One test |
uv run pytest -k supersede -x |
Tests matching a keyword, stop at first failure |
uv run ruff check . |
Lint (--fix to auto-fix) |
uv run ruff format . |
Format (--check to only verify) |
Before committing: uv run ruff check . && uv run ruff format --check . && uv run pytest.
To launch the UI with NextJS already built:
export INDIEK_API_ENV_FILE=".env" && INDIEK_API_STATIC_DIR="/Users/adrianradillo/prog/indiek/webUI/custom-graph-explorer/out" && uv run python -m indiek_api
The server prints IndieK Desktop: open http://127.0.0.1:<port>/api/session?token=… and opens
it: that single-use link logs the browser in (section 7, "Access control"). Restarting the server
invalidates the session: use the new link.
The server does not auto-reload: restart it after code changes.
4. Configuration
All settings are environment variables, read in indiek_api/settings.py.
| Variable | Default | Meaning |
|---|---|---|
IK_AUTHOR_PRIVATE_KEY |
— | 64-hex Ed25519 seed that signs writes (same variable as the ik CLI, see the indiek README). Overrides the OS-keychain key created or imported in the app (/api/identity). Without either, reads work and writes return 503 author_key_missing. |
INDIEK_API_ENV_FILE |
fastAPI/.env |
Dotenv file loaded at startup; real environment variables win. |
INDIEK_API_REGISTRY |
~/.indiek/workspaces.json |
Registered archive folders (JSON). |
INDIEK_API_PORT |
random free port | Fixed port (host is always 127.0.0.1). |
INDIEK_API_TOKEN |
random per launch | Fixed API token, development and tests only (≥ 16 characters); stays reusable, so …/api/session?token=<it> can be reopened. |
INDIEK_API_OPEN_BROWSER |
1 |
0 = only print the launch URL (same as --no-browser). |
INDIEK_API_CSP_REPORT_ONLY |
0 |
1 = send the CSP as Content-Security-Policy-Report-Only (debugging: violations are logged in the browser console, nothing is blocked). |
INDIEK_API_STATIC_DIR |
— | Built web UI to serve at / (see section 5). |
INDIEK_API_CORS_ORIGINS |
— | Comma-separated origins; only needed when a browser calls the API from another origin. |
Typical setups:
- Your key: put
IK_AUTHOR_PRIVATE_KEY=…infastAPI/.env(git-ignored; never commit it). - Experiments without touching your real registry:
INDIEK_API_REGISTRY=/tmp/ik-registry.json INDIEK_API_PORT=8765 uv run python -m indiek_api. - Browser testing with a stable URL: add
INDIEK_API_PORT=8765 INDIEK_API_TOKEN=<16+ chars>and openhttp://127.0.0.1:8765/api/session?token=<the token>once per browser.
Launched by the desktop shell (DESKTOP:REPO-SPLIT-3)
The Tauri shell starts the server as a child process with two flags (both usable without Tauri):
| Flag | Effect |
|---|---|
--handshake |
Print exactly one JSON line on stdout, {"indiek_api": "<version>", "port": <port>, "launch_url": "<url>"}, and never open a browser. All logs (uvicorn's access log included) go to stderr, so the one-time launch token travels only through the pipe to the parent. The shell opens launch_url in its webview. |
--exit-on-stdin-eof |
Shut down when stdin is closed: the server never outlives a crashed parent. |
Try it: uv run python -m indiek_api --handshake --exit-on-stdin-eof (Ctrl-D stops it).
Embedding the API as a library (DESKTOP:REUSE-SEAMS)
create_app(settings=None, *, workspaces=None, signer=None) accepts any object matching the
protocols of indiek_api/providers.py: WorkspaceProvider (list, register, remove,
service; default WorkspaceRegistry) and Signer (author, author_or_none; default
KeyProvider). The /api/identity/* routes are mounted only when the signer is also a
KeyManager (status, store, backup, read_backup), as KeyProvider is.
5. Working with the frontend
- Frontend development: run this server as usual and
npm run devinwebUI/custom-graph-explorer. Next proxies/api/*toINDIEK_API_URL(defaulthttp://127.0.0.1:8000), so the browser sees one origin — no CORS setting needed. Start this server withINDIEK_API_PORT=8000 INDIEK_API_TOKEN=<16+ chars>, then openhttp://localhost:3000/api/session?token=<the token>once: the cookie lands onlocalhost:3000and the proxy forwards it. - Desktop build (one process): build the UI, then point this server at it:
(cd ../webUI/custom-graph-explorer && npm run build) INDIEK_API_STATIC_DIR="$(cd ../webUI/custom-graph-explorer/out && pwd)" uv run python -m indiek_api
/api/*and/healthare the API; every other path is a file fromout/. - Changing the API contract: the frontend mirrors it by hand in
webUI/custom-graph-explorer/src/lib/types.tsandsrc/lib/api.ts. Update both sides (and their tests) together.
6. Code layout
indiek_api/
__main__.py entry point: `python -m indiek_api` → uvicorn on 127.0.0.1:<port>
app.py FastAPI app factory, all routes, static-files mount, CORS; main() = launcher
security.py access control: loopback Host, token (Bearer/cookie), write header, CSP
identity.py KeyProvider: the default signer (env key, else OS-keychain key via indiek.keys)
providers.py reuse seams: WorkspaceProvider, Signer, KeyManager protocols for create_app
schemas.py Pydantic request/response models (the wire contract)
archive_service.py ArchiveService: verified, cached, write-serialised access to ONE archive
workspaces.py registry of archive folders (workspace = pointer to a folder)
errors.py domain errors and their mapping to {"detail", "code"} responses
settings.py environment/dotenv configuration
tests/
conftest.py hermetic fixtures (see section 9)
test_api.py HTTP contract of every route
test_archive_service.py projection, caching, integrity, write serialisation
test_cli_parity.py results match the `ik` CLI (resource types, edge supersedes, timestamps, events)
test_security.py access control, launch link, CSP, launcher
test_identity.py signing identity routes (in-memory keychain, never the real one)
test_write_lock.py indiek's cross-process write lock seen through the API
test_desktop_seams.py injected providers, --handshake, --exit-on-stdin-eof (real subprocess)
fixtures/demo_archive/ a real archive built with the `ik` CLI README walkthrough
CHANGELOG.md, pyproject.toml, uv.lock, .python-version
How a request flows: app.py route → looks up the workspace in workspaces.py → gets the
folder's ArchiveService → which uses the indiek library → result serialised with a
schemas.py model. Errors raised anywhere are domain errors from errors.py, turned into
JSON by the exception handlers.
ArchiveService behaviour worth knowing:
- The archive is re-verified (chain, signatures, blobs) whenever its event logs change on
disk, whoever changed them: this API, the
ikCLI or agit pull. Otherwise the cached projection is reused. - A blob altered on disk without any log change is only caught at the next full verification (after the next log change).
- Writes are serialised by a lock inside this process only; it does not guard against the
ikCLI appending to the same archive at the same instant.
7. Routes
All archive routes live under /api/workspaces/{workspace_id}. Item references
accept a UUID or an e{n} alias; tag references a UUID or an m{n} alias.
Full request/response schemas: http://127.0.0.1:<port>/docs.
Access control (DESKTOP:LOCALHOST-TOKEN)
Design, threats and owner decisions: project_management/Design.md, cybersecurity section.
- Only loopback
Hostheaders are served (127.0.0.1,localhost,[::1], any port): defeats DNS rebinding. Applies to every path. - Every
/api/*route needs the per-launch token:Authorization: Bearer <token>(for the future Tauri shell) or the session cookieik_session_<port>(HttpOnly, SameSite=Strict).GET /api/session?token=<launch token>sets that cookie and redirects to/; the launch token works once (unless fixed withINDIEK_API_TOKEN)./healthand the UI files are open. POST/PUT/PATCH/DELETEon/api/*also needX-IndieK-Request: 1: a cross-origin page would need a CORS preflight, which is never granted, so other pages cannot write.- Every response carries a strict
Content-Security-Policy:script-srclists the SHA-256 of each inline script ofINDIEK_API_STATIC_DIR's HTML, computed at startup (restart afternpm run build); no'unsafe-inline'/'unsafe-eval'for scripts. Blob responses get a sandboxed policy (default-src 'none'; …; sandbox), plusX-Content-Type-Options: nosniff.
| Method & path | Body | Response |
|---|---|---|
GET /health |
— | {"status": "ok", "api_version": "<version>"} (no token needed) |
GET /api/session?token=… |
— | 303 → / + session cookie; 401 if the launch token is wrong or used |
GET /api/identity |
— | {public_key, source: "env" | "keychain" | "none", keychain_available} — never the secret |
POST /api/identity/generate |
{replace?: false} |
201, same shape; new key stored in the OS keychain |
POST /api/identity/import |
{seed_hex} | {backup, password}, plus replace? |
201, same shape |
POST /api/identity/backup |
{password} (≥ 12 characters) |
the encrypted backup file, attachment; filename="indiek-key-<8 hex>.ikkey", no-store (≈ 4 s: Argon2id) |
GET /api/workspaces |
— | [{workspace_id, name, path, created_at}] |
POST /api/workspaces |
{path} |
201 new / 200 already registered, same shape |
DELETE /api/workspaces/{id} |
— | 204; forgets the folder, files are never touched |
GET …/graph |
— | {head, meta_head, nodes[], edges[], tags[]} — supersedes applied, aliases, tag kinds; each edge has resource_type ("managed", or "none" without content) |
GET …/blobs/{sha256} |
— | blob bytes with their media type (hex, prefix allowed) |
GET …/events |
— | base + meta event logs in log order (like ik read) |
POST …/nodes |
{resource_type?: "managed", content, title?, media_type?} | {resource_type: "uri", uri, title?, media_type?} | {resource_type: "local_file", path, title?} |
201 {id, alias} |
PUT …/nodes/{id} |
{content, title?} (stored as Markdown) |
{id, alias} |
POST …/edges |
{source, target, content?} — omit content (or null) for an edge without content |
201 {id, alias} |
PUT …/edges/{id} |
{content?} — omit or null to remove the edge's content |
{id, alias} |
POST …/tags |
{name, description, kind: "node" | "edge"} |
201 {id, alias} |
POST …/tags/{tag}/assignments |
{target} |
201 {target, tags[]} — tag kind must match the target; only the tag's author may assign it |
Errors
Every error is {"detail": <message>, "code": <stable slug>}; clients branch on code.
| Status | code |
When |
|---|---|---|
| 404 | workspace_not_found, item_not_found, blob_not_found, archive_not_found |
unknown workspace/id/alias/hash, or the registered folder does not exist |
| 409 | archive_integrity |
broken chain, bad signature, missing blob |
| 409 | archive_locked |
another process (e.g. ik) held the archive's write lock .ik.lock for more than 10 s; retry |
| 403 | forbidden |
assigning another author's tag |
| 422 | archive_rule |
the library refused the operation (e.g. tag kind ≠ target kind) |
| 422 | (none) | FastAPI request validation; detail is a list of issues |
| 503 | author_key_missing |
a write without IK_AUTHOR_PRIVATE_KEY |
| 400 | invalid_host |
Host header is not a loopback name |
| 401 | unauthorized |
/api/* without a valid token (Bearer or session cookie), or a wrong/used launch link |
| 403 | missing_request_header |
a write without X-IndieK-Request: 1 |
| 409 | key_exists |
generate/import while the keychain already holds a key, without replace: true |
| 422 | invalid_key |
malformed seed_hex, or not exactly one of seed_hex / backup (never echoes the key) |
| 422 | invalid_backup |
not a key backup, wrong password, or a damaged file |
| 422 | weak_password |
backup password shorter than 12 characters |
| 503 | keychain_unavailable |
no usable OS keychain: use IK_AUTHOR_PRIVATE_KEY |
8. Recipe: adding a route
- Contract: add request/response models to
schemas.py. - Test first: add the HTTP test to
tests/test_api.py(and totest_cli_parity.pyif the CLI has an equivalent command, to prove identical results). - Logic: implement it as an
ArchiveServicemethod (with a unit test intest_archive_service.py) — routes stay thin. - Route: add it in
app.py, mapping inputs to the service and the result to the schema. - Errors: raise existing domain errors; if a new failure kind is needed, add it in
errors.pywith its own stablecode. - Frontend: mirror the types/function in
webUI/custom-graph-explorer/src/lib/. - Docs: this README's route table and
CHANGELOG.md; bumpversioninpyproject.tomlfor releases.
For the library side (indiek), learn the API from the indiek README; treat the CLI
as stable.
9. Testing
Tests are hermetic: nothing reads a dotenv file, your real registry or your real archives.
Main fixtures in tests/conftest.py:
| Fixture | Gives you |
|---|---|
author |
the API's configured author; its seed is exported as IK_AUTHOR_PRIVATE_KEY |
other_author |
a second author (e.g. for 403 forbidden cases) |
sample_archive |
throwaway archive under tmp_path: two nodes, one edge, one node tag, one assignment |
demo_archive |
private, mutable copy of tests/fixtures/demo_archive (e1–e10, m1–m6; e1 node and e8 edge superseded; e5 uri, e6 local_file; m1 node tag "theorem", m2 edge tag "proof") |
settings, client |
test settings (fixed token TEST_TOKEN) and an HTTP test client that calls the API as the UI does (127.0.0.1, Bearer token, write header; see api_client()) |
workspace |
sample_archive registered in place as a workspace |
Helpers: open_index(root) (open, verify and index an archive exactly as the CLI does),
build_sample_archive, tamper_last_signature (to test 409 archive_integrity).
10. Conventions
- Python 3.10+ style with strict type hints everywhere; PEP 8 via ruff (line length 100).
uvonly.- Keep routes thin; logic lives in
ArchiveService; every error has a stablecode. - Never bind to anything but
127.0.0.1; never log or return secrets (the launch URL printed at startup is the one deliberate exception: it is single-use). - Record significant changes in
CHANGELOG.md.
11. Troubleshooting
| Symptom | Fix |
|---|---|
503 author_key_missing on writes |
Create or import a key in the app (Author key dialog), or set IK_AUTHOR_PRIVATE_KEY (env or fastAPI/.env) and restart |
The app shows source: env and ignores a key created in it |
IK_AUTHOR_PRIVATE_KEY is set (env or fastAPI/.env) and wins: remove it to use the keychain key |
409 archive_integrity |
The archive on disk is inconsistent (e.g. a manual edit or bad merge); check it with the ik CLI |
409 archive_locked |
Another process is writing to the archive (a long ik ingest/ik seal, or a stuck process); retry, or stop it |
404 archive_not_found when registering |
The folder does not exist. Paths are ~-expanded and resolved relative to the server's working directory, so prefer absolute paths |
Address already in use |
Another server on the fixed INDIEK_API_PORT: stop it, or unset the variable (random port) |
401 unauthorized everywhere |
Open the launch link the server printed (each restart prints a new one; a link works once) |
403 missing_request_header |
A client other than the UI: send X-IndieK-Request: 1 on writes |
| UI blank, CSP errors in the browser console | The UI was rebuilt after the server started: restart it (hashes are computed at startup); INDIEK_API_CSP_REPORT_ONLY=1 to debug |
Frontend says network_error |
Server not running, or frontend's INDIEK_API_URL points to another port |
uv sync cannot find indiek |
No network access to PyPI, or uv.lock pins an indiek version not published yet (section 1) |
12. Support IndieK
IndieK is free and open-source software. If it's useful to you, you can support its development:
- GitHub Sponsors, for developers with a GitHub account (0% platform fees)
- Ko-fi, one-time or monthly, no account needed
All donations strictly fund the open-source core of IndieK and its public infrastructure.
Metadata
Release files for indiek-api 0.17.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| indiek_api-0.17.0.tar.gz | 684.9 kB | With provenance | Details for indiek_api-0.17.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| indiek_api-0.17.0-py3-none-any.whl | Python 3 | none | any | Details for indiek_api-0.17.0-py3-none-any.whl |
Total release size: 720.8 kB
Release files / indiek_api-0.17.0.tar.gz
| Download URL | indiek_api-0.17.0.tar.gz |
|---|---|
| Size | 684.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c23a04745a603011f3a21091bda0079876d724751e81e8f2c923acf376682d23
|
|
BLAKE2b-256 checksum How to use checksums |
a1cd66ff636b8f0694401ee9086623711d2ab2b10d96d100847502c9452695a3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logRelease files / indiek_api-0.17.0-py3-none-any.whl
| Download URL | indiek_api-0.17.0-py3-none-any.whl |
|---|---|
| Size | 35.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
adf78db5b6b84abaa0bba131d2f3bb51f41013e9d6208bca06e26062aec75500
|
|
BLAKE2b-256 checksum How to use checksums |
cee682c17a381f988b7f4b26d9139ee8fb3bc087ffcaf82d8743504f45f24aef
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logSecurity for indiek-api 0.17.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for indiek_api-0.17.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 1, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.