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 +3 -0
- indiek_api/__main__.py +3 -0
- indiek_api/app.py +409 -0
- indiek_api/archive_service.py +294 -0
- indiek_api/errors.py +173 -0
- indiek_api/identity.py +123 -0
- indiek_api/providers.py +59 -0
- indiek_api/schemas.py +208 -0
- indiek_api/security.py +251 -0
- indiek_api/settings.py +69 -0
- indiek_api/workspaces.py +192 -0
- indiek_api-0.17.0.dist-info/METADATA +343 -0
- indiek_api-0.17.0.dist-info/RECORD +16 -0
- indiek_api-0.17.0.dist-info/WHEEL +4 -0
- indiek_api-0.17.0.dist-info/entry_points.txt +2 -0
- indiek_api-0.17.0.dist-info/licenses/LICENSE +201 -0
indiek_api/__init__.py
ADDED
indiek_api/__main__.py
ADDED
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()
|