indiek-api 0.17.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
indiek_api/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Local HTTP API serving IndieK archives to the IndieK Desktop App."""
2
+
3
+ __version__ = "0.13.0" # keep in sync with pyproject.toml
indiek_api/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from indiek_api.app import main
2
+
3
+ main()
indiek_api/app.py ADDED
@@ -0,0 +1,409 @@
1
+ """FastAPI application: workspace-scoped read/write access to local IndieK archives.
2
+
3
+ Run with ``uv run python -m indiek_api`` (binds 127.0.0.1 only).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import logging
11
+ import socket
12
+ import sys
13
+ import threading
14
+ import webbrowser
15
+ from collections.abc import Sequence
16
+ from dataclasses import replace
17
+ from typing import TYPE_CHECKING, BinaryIO, Literal
18
+ from uuid import UUID
19
+
20
+ from fastapi import APIRouter, FastAPI, Response
21
+ from fastapi.middleware.cors import CORSMiddleware
22
+ from fastapi.responses import FileResponse
23
+ from fastapi.staticfiles import StaticFiles
24
+
25
+ from indiek import Author, MediaType, ResourceType
26
+
27
+ from indiek_api import __version__
28
+ from indiek_api.archive_service import (
29
+ ArchiveService,
30
+ EdgeView,
31
+ EventView,
32
+ NodeView,
33
+ Projection,
34
+ )
35
+ from indiek_api.errors import (
36
+ BlobNotFoundError,
37
+ InvalidKeyError,
38
+ ItemNotFoundError,
39
+ install_handlers,
40
+ )
41
+ from indiek_api.identity import KeyProvider
42
+ from indiek_api.providers import KeyManager, Signer, WorkspaceProvider
43
+ from indiek_api.schemas import (
44
+ Created,
45
+ EdgeCreate,
46
+ EdgeOut,
47
+ EdgeWrite,
48
+ EventOut,
49
+ GraphOut,
50
+ IdentityBackup,
51
+ IdentityGenerate,
52
+ IdentityImport,
53
+ IdentityOut,
54
+ LocalFileNodeCreate,
55
+ ManagedNodeCreate,
56
+ NodeCreate,
57
+ NodeOut,
58
+ NodeWrite,
59
+ TagAssignment,
60
+ TagCreate,
61
+ TagOut,
62
+ TargetTags,
63
+ UriNodeCreate,
64
+ WorkspaceCreate,
65
+ WorkspaceOut,
66
+ )
67
+ from indiek_api.security import (
68
+ SESSION_PATH,
69
+ AccessControlMiddleware,
70
+ AccessTokens,
71
+ build_csp,
72
+ inline_script_hashes,
73
+ )
74
+ from indiek_api.settings import Settings
75
+ from indiek_api.workspaces import WorkspaceRecord, WorkspaceRegistry
76
+
77
+ if TYPE_CHECKING:
78
+ import uvicorn
79
+
80
+
81
+ def _created(
82
+ service: ArchiveService, item_id: UUID, kind: Literal["node", "edge", "tag"]
83
+ ) -> Created:
84
+ projection = service.projection()
85
+ pool = {"node": projection.nodes, "edge": projection.edges, "tag": projection.tags}[kind]
86
+ item = pool[str(item_id)]
87
+ return Created(id=item.id, alias=item.alias)
88
+
89
+
90
+ def _find_item(projection: Projection, ref: str) -> NodeView | EdgeView:
91
+ """Resolve a UUID or e{n} alias to a current base-network item."""
92
+ for pool in (projection.nodes, projection.edges):
93
+ if ref in pool:
94
+ return pool[ref]
95
+ for item in pool.values():
96
+ if item.alias == ref:
97
+ return item
98
+ raise ItemNotFoundError(f"'{ref}' is not a node or edge of this archive")
99
+
100
+
101
+ def create_app(
102
+ settings: Settings | None = None,
103
+ *,
104
+ workspaces: WorkspaceProvider | None = None,
105
+ signer: Signer | None = None,
106
+ ) -> FastAPI:
107
+ """Build the API. ``workspaces`` and ``signer`` default to local folders
108
+ (`WorkspaceRegistry`) and `KeyProvider`; see `indiek_api.providers`."""
109
+ settings = settings or Settings.from_env()
110
+ settings.load_env_file()
111
+ registry = workspaces if workspaces is not None else WorkspaceRegistry(settings.registry_path)
112
+
113
+ app = FastAPI(title="IndieK local API", version=__version__)
114
+ install_handlers(app)
115
+ access = AccessTokens(fixed=settings.token)
116
+ app.state.access = access
117
+ app.add_middleware(
118
+ AccessControlMiddleware,
119
+ tokens=access,
120
+ port=settings.port,
121
+ csp=build_csp(inline_script_hashes(settings.static_dir)),
122
+ csp_report_only=settings.csp_report_only,
123
+ )
124
+ # Added last = outermost: answers CORS preflights (only for explicitly allowed origins).
125
+ if settings.cors_origins:
126
+ app.add_middleware(
127
+ CORSMiddleware,
128
+ allow_origins=list(settings.cors_origins),
129
+ allow_methods=["*"],
130
+ allow_headers=["*"],
131
+ )
132
+
133
+ @app.get("/health")
134
+ def health() -> dict[str, str]:
135
+ return {"status": "ok", "api_version": __version__}
136
+
137
+ # -- signing identity (DESKTOP:KEY-ONBOARDING) -------------------------------------
138
+
139
+ identity: Signer = signer if signer is not None else KeyProvider()
140
+ app.state.identity = identity
141
+ if isinstance(identity, KeyManager):
142
+ _add_identity_routes(app, identity)
143
+
144
+ api = APIRouter(prefix="/api/workspaces")
145
+
146
+ # --- workspaces ----------------------------------------------------------
147
+
148
+ @api.get("", response_model=list[WorkspaceOut])
149
+ def list_workspaces() -> list[WorkspaceRecord]:
150
+ return registry.list()
151
+
152
+ @api.post("", response_model=WorkspaceOut, status_code=201)
153
+ def register_workspace(payload: WorkspaceCreate, response: Response) -> WorkspaceRecord:
154
+ author = identity.author_or_none() if payload.create else None
155
+ record, created = registry.register(payload.path, create=payload.create, author=author)
156
+ if not created:
157
+ response.status_code = 200
158
+ return record
159
+
160
+ @api.delete("/{workspace_id}", status_code=204)
161
+ def remove_workspace(workspace_id: str) -> None:
162
+ registry.remove(workspace_id)
163
+
164
+ # --- reads ---------------------------------------------------------------
165
+
166
+ @api.get("/{workspace_id}/graph", response_model=GraphOut)
167
+ def graph(workspace_id: str) -> GraphOut:
168
+ projection = registry.service(workspace_id).projection()
169
+ return GraphOut(
170
+ head=projection.head,
171
+ meta_head=projection.meta_head,
172
+ nodes=[NodeOut.model_validate(node) for node in projection.nodes.values()],
173
+ edges=[EdgeOut.model_validate(edge) for edge in projection.edges.values()],
174
+ tags=[TagOut.model_validate(tag) for tag in projection.tags.values()],
175
+ )
176
+
177
+ @api.get("/{workspace_id}/blobs/{blob_hash}")
178
+ def blob(workspace_id: str, blob_hash: str) -> FileResponse:
179
+ service = registry.service(workspace_id)
180
+ try:
181
+ path = service.blob_path(blob_hash)
182
+ except KeyError:
183
+ raise BlobNotFoundError(f"No blob '{blob_hash}' in this archive") from None
184
+ media_type = service.projection().blob_media_types.get(path.name)
185
+ return FileResponse(path, media_type=media_type or "application/octet-stream")
186
+
187
+ @api.get("/{workspace_id}/events", response_model=list[EventOut])
188
+ def events(workspace_id: str) -> list[EventView]:
189
+ return registry.service(workspace_id).events()
190
+
191
+ # --- writes --------------------------------------------------------------
192
+
193
+ @api.post("/{workspace_id}/nodes", response_model=Created, status_code=201)
194
+ def create_node(workspace_id: str, payload: NodeCreate) -> Created:
195
+ service = registry.service(workspace_id)
196
+ author = identity.author()
197
+ match payload:
198
+ case ManagedNodeCreate():
199
+ node_id = service.write(
200
+ author,
201
+ lambda ik: ik.create_node(
202
+ payload.content,
203
+ media_type=MediaType(payload.media_type),
204
+ title=payload.title,
205
+ ),
206
+ )
207
+ case UriNodeCreate():
208
+ node_id = service.write(
209
+ author,
210
+ lambda ik: ik.create_node(
211
+ resource_type=ResourceType.URI,
212
+ uri=str(payload.uri),
213
+ title=payload.title,
214
+ media_type=payload.media_type,
215
+ ),
216
+ )
217
+ case LocalFileNodeCreate():
218
+ node_id = service.write(
219
+ author,
220
+ lambda ik: ik.create_node(
221
+ resource_type=ResourceType.LOCAL_FILE,
222
+ path=payload.path,
223
+ title=payload.title,
224
+ ),
225
+ )
226
+ return _created(service, node_id, "node")
227
+
228
+ @api.put("/{workspace_id}/nodes/{node_id}", response_model=Created)
229
+ def supersede_node(workspace_id: str, node_id: str, payload: NodeWrite) -> Created:
230
+ service = registry.service(workspace_id)
231
+ revised_id = service.write(
232
+ identity.author(),
233
+ lambda ik: ik.supersede_node(node_id, payload.content, title=payload.title),
234
+ )
235
+ return _created(service, revised_id, "node")
236
+
237
+ @api.post("/{workspace_id}/edges", response_model=Created, status_code=201)
238
+ def create_edge(workspace_id: str, payload: EdgeCreate) -> Created:
239
+ service = registry.service(workspace_id)
240
+ edge_id = service.write(
241
+ identity.author(),
242
+ lambda ik: ik.create_edge(payload.source, payload.target, payload.content),
243
+ )
244
+ return _created(service, edge_id, "edge")
245
+
246
+ @api.put("/{workspace_id}/edges/{edge_id}", response_model=Created)
247
+ def supersede_edge(workspace_id: str, edge_id: str, payload: EdgeWrite) -> Created:
248
+ service = registry.service(workspace_id)
249
+ revised_id = service.write(
250
+ identity.author(), lambda ik: ik.supersede_edge(edge_id, payload.content)
251
+ )
252
+ return _created(service, revised_id, "edge")
253
+
254
+ @api.post("/{workspace_id}/tags", response_model=Created, status_code=201)
255
+ def create_tag(workspace_id: str, payload: TagCreate) -> Created:
256
+ service = registry.service(workspace_id)
257
+ tag_id = service.write(
258
+ identity.author(),
259
+ lambda ik: ik.create_tag(payload.name, payload.description, kind=payload.kind),
260
+ )
261
+ return _created(service, tag_id, "tag")
262
+
263
+ @api.post(
264
+ "/{workspace_id}/tags/{tag_id}/assignments", response_model=TargetTags, status_code=201
265
+ )
266
+ def assign_tag(workspace_id: str, tag_id: str, payload: TagAssignment) -> TargetTags:
267
+ service = registry.service(workspace_id)
268
+ service.write(identity.author(), lambda ik: ik.set_tag(tag_id, payload.target))
269
+ target = _find_item(service.projection(), payload.target)
270
+ return TargetTags(target=target.id, tags=list(target.tags))
271
+
272
+ app.include_router(api)
273
+ if settings.static_dir is not None and settings.static_dir.is_dir():
274
+ app.mount("/", StaticFiles(directory=settings.static_dir, html=True), name="ui")
275
+ return app
276
+
277
+
278
+ def _add_identity_routes(app: FastAPI, manager: KeyManager) -> None:
279
+ """The signing-key management routes (DESKTOP:KEY-ONBOARDING)."""
280
+
281
+ def identity_out() -> IdentityOut:
282
+ author, source, available = manager.status()
283
+ return IdentityOut(
284
+ public_key=author.public_key_hex if author else None,
285
+ source=source.value,
286
+ keychain_available=available,
287
+ )
288
+
289
+ @app.get("/api/identity", response_model=IdentityOut)
290
+ def get_identity() -> IdentityOut:
291
+ return identity_out()
292
+
293
+ @app.post("/api/identity/generate", response_model=IdentityOut, status_code=201)
294
+ def generate_identity(payload: IdentityGenerate) -> IdentityOut:
295
+ manager.store(Author.generate(), replace=payload.replace)
296
+ return identity_out()
297
+
298
+ @app.post("/api/identity/import", response_model=IdentityOut, status_code=201)
299
+ def import_identity(payload: IdentityImport) -> IdentityOut:
300
+ if (payload.seed_hex is None) == (payload.backup is None):
301
+ raise InvalidKeyError("Send either seed_hex or a backup file with its password.")
302
+ if payload.seed_hex is not None:
303
+ try:
304
+ author = Author.from_seed_hex(payload.seed_hex.strip())
305
+ except ValueError:
306
+ raise InvalidKeyError(
307
+ "The key must be a 32-byte Ed25519 seed written as 64 hex characters."
308
+ ) from None
309
+ else:
310
+ if not payload.password:
311
+ raise InvalidKeyError("A backup file needs its password.")
312
+ author = manager.read_backup(payload.backup or "", payload.password)
313
+ manager.store(author, replace=payload.replace)
314
+ return identity_out()
315
+
316
+ @app.post("/api/identity/backup")
317
+ def backup_identity(payload: IdentityBackup) -> Response:
318
+ author, data = manager.backup(payload.password)
319
+ filename = f"indiek-key-{author.public_key_hex[:8]}.ikkey"
320
+ return Response(
321
+ content=data,
322
+ media_type="application/json",
323
+ headers={
324
+ "Content-Disposition": f'attachment; filename="{filename}"',
325
+ "Cache-Control": "no-store",
326
+ },
327
+ )
328
+
329
+
330
+ def bind_loopback(host: str, port: int | None) -> socket.socket:
331
+ """A listening socket on ``host``; ``port=None`` lets the OS pick a free port."""
332
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
333
+ try:
334
+ sock.bind((host, port or 0))
335
+ sock.listen(128)
336
+ except OSError:
337
+ sock.close()
338
+ raise
339
+ return sock
340
+
341
+
342
+ def launch_url(host: str, port: int, launch_token: str) -> str:
343
+ return f"http://{host}:{port}{SESSION_PATH}?token={launch_token}"
344
+
345
+
346
+ def _stop_on_eof(stream: BinaryIO, server: uvicorn.Server) -> None:
347
+ """Read (and discard) ``stream`` until EOF, then ask ``server`` to shut down."""
348
+ while stream.read(4096):
349
+ pass
350
+ server.should_exit = True
351
+
352
+
353
+ def _serve(
354
+ app: FastAPI,
355
+ sockets: list[socket.socket],
356
+ *,
357
+ handshake: bool = False,
358
+ exit_on_stdin_eof: bool = False,
359
+ ) -> None:
360
+ import uvicorn
361
+ from uvicorn.config import LOGGING_CONFIG
362
+
363
+ # Handshake mode: stdout carries only the handshake line, so uvicorn's own logging
364
+ # config (access log on stdout) is skipped and its loggers reach the root logger (stderr).
365
+ log_config = None if handshake else LOGGING_CONFIG
366
+ server = uvicorn.Server(uvicorn.Config(app, log_level="info", log_config=log_config))
367
+ if exit_on_stdin_eof:
368
+ threading.Thread(target=_stop_on_eof, args=(sys.stdin.buffer, server), daemon=True).start()
369
+ server.run(sockets=sockets)
370
+
371
+
372
+ def main(argv: Sequence[str] | None = None) -> None:
373
+ parser = argparse.ArgumentParser(
374
+ prog="python -m indiek_api", description="Serve the IndieK Desktop App on this machine."
375
+ )
376
+ parser.add_argument(
377
+ "--no-browser", action="store_true", help="print the launch URL without opening it"
378
+ )
379
+ parser.add_argument(
380
+ "--handshake",
381
+ action="store_true",
382
+ help="for a parent process (desktop shell): print one JSON line "
383
+ '{"indiek_api", "port", "launch_url"} on stdout, log to stderr, never open a browser',
384
+ )
385
+ parser.add_argument(
386
+ "--exit-on-stdin-eof",
387
+ action="store_true",
388
+ help="shut down when stdin is closed (the parent process went away)",
389
+ )
390
+ args = parser.parse_args(argv)
391
+ logging.basicConfig(level=logging.INFO)
392
+ settings = Settings.from_env()
393
+ sock = bind_loopback(settings.host, settings.port)
394
+ try:
395
+ settings = replace(settings, port=sock.getsockname()[1])
396
+ app = create_app(settings)
397
+ launch = app.state.access.launch
398
+ assert settings.port is not None and launch is not None
399
+ url = launch_url(settings.host, settings.port, launch)
400
+ if args.handshake:
401
+ line = {"indiek_api": __version__, "port": settings.port, "launch_url": url}
402
+ print(json.dumps(line), flush=True)
403
+ else:
404
+ print(f"IndieK Desktop: open {url}", flush=True)
405
+ if settings.open_browser and not args.no_browser:
406
+ webbrowser.open(url)
407
+ _serve(app, [sock], handshake=args.handshake, exit_on_stdin_eof=args.exit_on_stdin_eof)
408
+ finally:
409
+ sock.close()