causal-memory-layer 0.4.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.
api/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # api package
api/server.py ADDED
@@ -0,0 +1,515 @@
1
+ """
2
+ CML Audit API Server (FastAPI)
3
+
4
+ Endpoints:
5
+ POST /audit — Audit a JSONL log
6
+ Body: {"log": "<JSONL>", "config": "<YAML>", "format": "json|markdown|text"}
7
+ POST /audit/file — Audit an uploaded JSONL file (multipart/form-data)
8
+ POST /ingest — Append records to a named log store
9
+ Body: {"log_name": "...", "records": [...]}
10
+ GET /records/{log_name} — List records in a named log
11
+ GET /records/{log_name}/audit — Run audit on a stored log
12
+ GET /chain/{log_name}/{id} — Reconstruct causal chain for a record
13
+ POST /ctag/decode — Decode a 16-bit CTAG value
14
+ Body: {"ctag": <int|hex_string>}
15
+ GET /health — Health check
16
+
17
+ Authentication:
18
+ Set CML_API_TOKEN env var to enable Bearer-token auth on all endpoints
19
+ except /health, /docs*, /redoc*. Community tier: unset = no auth.
20
+ Token comparison is constant-time (hmac.compare_digest).
21
+
22
+ Store backend:
23
+ Set CML_STORE_PATH to a .db file path to enable SQLite persistence.
24
+ Unset = in-memory store (ephemeral, community tier).
25
+
26
+ Hardening env vars:
27
+ CML_CORS_ORIGINS — comma-separated allowed origins. With auth enabled the
28
+ default is empty (deny). Without auth the default is
29
+ "*". Use "*" explicitly to opt into permissive CORS.
30
+ CML_DISABLE_DOCS — when truthy, hides /docs and /redoc.
31
+ CML_STORE_TTL — SQLite TTL in seconds (1..31_536_000, default 86_400).
32
+
33
+ Rate limiting (slowapi):
34
+ CML_RATE_LIMIT_ENABLED — master switch (default: on).
35
+ CML_RATE_LIMIT_DEFAULT — default per-key budget (default: "60/minute").
36
+ CML_RATE_LIMIT_INGEST — override for /ingest (default: "30/minute").
37
+ CML_RATE_LIMIT_AUDIT — override for /audit, /audit/file, and
38
+ /records/{log_name}/audit (default: "30/minute").
39
+ CML_RATE_LIMIT_RECORDS — override for /records/{log_name}
40
+ (default: "120/minute").
41
+ CML_RATE_LIMIT_CHAIN — override for /chain/{log_name}/{id}
42
+ (default: "120/minute").
43
+ CML_RATE_LIMIT_CTAG — override for /ctag/decode (default: "300/minute").
44
+ CML_RATE_LIMIT_BACKEND — slowapi storage URI (default: "memory://"; e.g.
45
+ "redis://host:6379" for multi-replica deploys).
46
+ CML_TRUST_PROXY — when truthy, key by X-Forwarded-For. Off by
47
+ default — trusting it blindly enables bypass.
48
+
49
+ Run:
50
+ uvicorn api.server:app --reload --port 8080
51
+ """
52
+
53
+ from __future__ import annotations
54
+
55
+ import hashlib
56
+ import hmac
57
+ import json
58
+ import logging
59
+ import os
60
+ import re
61
+ import sys
62
+ from typing import Optional
63
+
64
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
65
+
66
+ from fastapi import FastAPI, HTTPException, Header, Request, UploadFile, File, Body
67
+ from fastapi.middleware.cors import CORSMiddleware
68
+ from fastapi.responses import JSONResponse, PlainTextResponse
69
+ from pydantic import BaseModel
70
+ from slowapi import Limiter
71
+ from slowapi.errors import RateLimitExceeded
72
+
73
+ import cml
74
+ from cml import (
75
+ CausalRecord, load_jsonl, records_to_index,
76
+ AuditEngine, AuditConfig, AuditResult,
77
+ reconstruct_chain,
78
+ to_markdown, to_json, to_text,
79
+ decode_ctag,
80
+ )
81
+ from api.store import InMemoryStore, SQLiteStore, StoreLimitError
82
+
83
+
84
+ logger = logging.getLogger("cml.api")
85
+
86
+
87
+ def _env_bool(name: str, default: bool = False) -> bool:
88
+ val = os.environ.get(name)
89
+ if val is None:
90
+ return default
91
+ return val.strip().lower() in {"1", "true", "yes", "on"}
92
+
93
+
94
+ def _env_int(name: str, default: int, *, minimum: int = 0, maximum: int = 2**31 - 1) -> int:
95
+ raw = os.environ.get(name)
96
+ if raw is None or raw == "":
97
+ return default
98
+ try:
99
+ val = int(raw)
100
+ except (TypeError, ValueError):
101
+ logger.warning("Invalid %s=%r — falling back to default %d", name, raw, default)
102
+ return default
103
+ if val < minimum or val > maximum:
104
+ logger.warning(
105
+ "%s=%d out of range [%d, %d] — clamping to default %d",
106
+ name, val, minimum, maximum, default,
107
+ )
108
+ return default
109
+ return val
110
+
111
+
112
+ def _env_csv(name: str) -> list[str]:
113
+ raw = os.environ.get(name, "")
114
+ return [item.strip() for item in raw.split(",") if item.strip()]
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Configuration
119
+ # ---------------------------------------------------------------------------
120
+ #
121
+ # CML_API_TOKEN — Bearer token; unset disables auth (community tier)
122
+ # CML_CORS_ORIGINS — comma-separated allowed origins. Required when token
123
+ # auth is enabled; otherwise defaults to "*".
124
+ # Use "*" explicitly to opt into permissive CORS.
125
+ # CML_DISABLE_DOCS — when truthy, disables /docs and /redoc (recommended
126
+ # in production deployments behind authentication).
127
+ # CML_STORE_PATH — SQLite DB path; empty/unset → in-memory ephemeral
128
+ # CML_STORE_TTL — TTL in seconds (default 86400 = 24h, 0 < ttl ≤ 1y)
129
+ # ---------------------------------------------------------------------------
130
+
131
+ _API_TOKEN = os.environ.get("CML_API_TOKEN") or None
132
+ _DISABLE_DOCS = _env_bool("CML_DISABLE_DOCS", default=False)
133
+
134
+
135
+ def _resolve_cors_origins() -> list[str]:
136
+ """Pick safe CORS defaults.
137
+
138
+ With auth enabled, default-deny (empty list) unless explicitly configured.
139
+ Without auth, "*" is acceptable since there are no credentials to leak.
140
+ Explicit CML_CORS_ORIGINS=* opts in to permissive CORS.
141
+ """
142
+ configured = _env_csv("CML_CORS_ORIGINS")
143
+ if configured:
144
+ return configured
145
+ if _API_TOKEN:
146
+ # Default-deny: do not allow arbitrary cross-origin browsers to reach
147
+ # an authenticated API. Operators must opt in explicitly.
148
+ return []
149
+ return ["*"]
150
+
151
+
152
+ _CORS_ORIGINS = _resolve_cors_origins()
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Rate limiting configuration
157
+ # ---------------------------------------------------------------------------
158
+
159
+ _RATE_LIMIT_ENABLED = _env_bool("CML_RATE_LIMIT_ENABLED", default=True)
160
+ _RATE_LIMIT_DEFAULT = os.environ.get("CML_RATE_LIMIT_DEFAULT") or "60/minute"
161
+ _RATE_LIMIT_INGEST = os.environ.get("CML_RATE_LIMIT_INGEST") or "30/minute"
162
+ _RATE_LIMIT_AUDIT = os.environ.get("CML_RATE_LIMIT_AUDIT") or "30/minute"
163
+ _RATE_LIMIT_RECORDS = os.environ.get("CML_RATE_LIMIT_RECORDS") or "120/minute"
164
+ _RATE_LIMIT_CHAIN = os.environ.get("CML_RATE_LIMIT_CHAIN") or "120/minute"
165
+ _RATE_LIMIT_CTAG = os.environ.get("CML_RATE_LIMIT_CTAG") or "300/minute"
166
+ _RATE_LIMIT_BACKEND = os.environ.get("CML_RATE_LIMIT_BACKEND") or "memory://"
167
+ _TRUST_PROXY = _env_bool("CML_TRUST_PROXY", default=False)
168
+
169
+
170
+ def _rate_limit_key(request: "Request") -> str:
171
+ """Bucket per Bearer token, fall back to client IP.
172
+
173
+ The token is hashed so log lines and slowapi keys never carry the raw
174
+ secret. ``X-Forwarded-For`` is honoured only when ``CML_TRUST_PROXY``
175
+ is set — trusting it by default would let any client spoof their key.
176
+ """
177
+ auth = request.headers.get("authorization", "")
178
+ if auth.startswith("Bearer "):
179
+ token = auth[7:].strip()
180
+ if token:
181
+ digest = hashlib.sha256(token.encode("utf-8")).hexdigest()[:16]
182
+ return f"tok:{digest}"
183
+ if _TRUST_PROXY:
184
+ forwarded = request.headers.get("x-forwarded-for", "")
185
+ if forwarded:
186
+ # Use the leftmost (original client) entry. Subsequent entries
187
+ # are upstream proxies we control under CML_TRUST_PROXY.
188
+ return f"ip:{forwarded.split(',')[0].strip()}"
189
+ client = request.client.host if request.client else "unknown"
190
+ return f"ip:{client}"
191
+
192
+
193
+ limiter = Limiter(
194
+ key_func=_rate_limit_key,
195
+ default_limits=[_RATE_LIMIT_DEFAULT],
196
+ storage_uri=_RATE_LIMIT_BACKEND,
197
+ enabled=_RATE_LIMIT_ENABLED,
198
+ )
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # App setup
203
+ # ---------------------------------------------------------------------------
204
+
205
+ app = FastAPI(
206
+ title="CML Audit API",
207
+ description=(
208
+ "Causal Memory Layer — REST API for causal log ingestion, "
209
+ "audit, and chain reconstruction."
210
+ ),
211
+ version=cml.__version__,
212
+ docs_url=None if _DISABLE_DOCS else "/docs",
213
+ redoc_url=None if _DISABLE_DOCS else "/redoc",
214
+ )
215
+
216
+ def _rate_limit_handler(request: "Request", exc: RateLimitExceeded) -> JSONResponse:
217
+ """Build a 429 response that always carries ``Retry-After``.
218
+
219
+ slowapi's default handler omits ``Retry-After`` unless ``headers_enabled``
220
+ is set on the limiter, but enabling that flag conflicts with routes that
221
+ return ``Response`` objects directly. We compute the retry window from
222
+ the underlying ``RateLimitItem`` so callers always know how long to back
223
+ off, regardless of which route was throttled.
224
+ """
225
+ retry_after = 60
226
+ try:
227
+ retry_after = int(exc.limit.limit.get_expiry())
228
+ except (AttributeError, TypeError, ValueError):
229
+ pass
230
+ response = JSONResponse(
231
+ {"detail": f"Rate limit exceeded: {exc.detail}"},
232
+ status_code=429,
233
+ )
234
+ response.headers["Retry-After"] = str(retry_after)
235
+ return response
236
+
237
+
238
+ app.state.limiter = limiter
239
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_handler)
240
+
241
+ # Methods are restricted to those the API actually serves. Allowed headers are
242
+ # limited to what the API consumes — anything else gets blocked at the browser.
243
+ app.add_middleware(
244
+ CORSMiddleware,
245
+ allow_origins=_CORS_ORIGINS,
246
+ allow_methods=["GET", "POST"],
247
+ allow_headers=["Authorization", "Content-Type"],
248
+ )
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # Bearer-token auth (enabled when CML_API_TOKEN env var is set)
252
+ # ---------------------------------------------------------------------------
253
+
254
+ if _API_TOKEN:
255
+ from starlette.middleware.base import BaseHTTPMiddleware
256
+ from starlette.requests import Request
257
+ from starlette.responses import JSONResponse as _AuthJSONResponse
258
+
259
+ _TOKEN_BYTES = _API_TOKEN.encode("utf-8")
260
+
261
+ class _BearerAuthMiddleware(BaseHTTPMiddleware):
262
+ _PUBLIC_EXACT = {"/health", "/openapi.json"}
263
+ _PUBLIC_PREFIX = ("/docs", "/redoc")
264
+
265
+ async def dispatch(self, request: Request, call_next):
266
+ path = request.url.path
267
+ if path in self._PUBLIC_EXACT or path.startswith(self._PUBLIC_PREFIX):
268
+ return await call_next(request)
269
+ auth = request.headers.get("authorization", "")
270
+ presented = auth[7:].encode("utf-8") if auth.startswith("Bearer ") else b""
271
+ # Constant-time compare to prevent token-recovery via timing.
272
+ if not hmac.compare_digest(presented, _TOKEN_BYTES):
273
+ client = request.client.host if request.client else "unknown"
274
+ logger.warning("Auth failure on %s from %s", path, client)
275
+ return _AuthJSONResponse(
276
+ {"detail": "Invalid or missing Bearer token."},
277
+ status_code=401,
278
+ )
279
+ return await call_next(request)
280
+
281
+ app.add_middleware(_BearerAuthMiddleware)
282
+
283
+ # ---------------------------------------------------------------------------
284
+ # Log store (pluggable backend)
285
+ #
286
+ # CML_STORE_PATH → SQLiteStore with TTL eviction (persistent)
287
+ # unset → InMemoryStore (community tier, ephemeral)
288
+ # CML_STORE_TTL → TTL in seconds (default: 86400 = 24h)
289
+ # ---------------------------------------------------------------------------
290
+
291
+ _store_path = os.environ.get("CML_STORE_PATH", "")
292
+ # TTL: clamp to (0, 1 year]. Non-positive or non-integer values fall back to
293
+ # 24h so a malformed env var cannot disable eviction or crash startup.
294
+ _store_ttl = _env_int("CML_STORE_TTL", default=86_400, minimum=1, maximum=31_536_000)
295
+
296
+ _store = (
297
+ SQLiteStore(_store_path, ttl_seconds=_store_ttl)
298
+ if _store_path
299
+ else InMemoryStore()
300
+ )
301
+
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # Input validation helpers
305
+ # ---------------------------------------------------------------------------
306
+
307
+ # log_name flows into SQLite parameters and HTTP path segments. Parameterized
308
+ # queries already prevent SQL injection, but unconstrained names enable
309
+ # resource exhaustion (millions of distinct logs) and confusing path routing.
310
+ _LOG_NAME_RE = re.compile(r"^[A-Za-z0-9._\-]{1,128}$")
311
+
312
+
313
+ def _validate_log_name(log_name: str) -> str:
314
+ if not isinstance(log_name, str) or not _LOG_NAME_RE.match(log_name):
315
+ raise HTTPException(
316
+ status_code=422,
317
+ detail=(
318
+ "Invalid log_name: must be 1-128 chars, "
319
+ "alphanumeric plus '.', '_', '-'."
320
+ ),
321
+ )
322
+ return log_name
323
+
324
+
325
+ def _get_log(log_name: str) -> list[CausalRecord]:
326
+ return _store.get(log_name)
327
+
328
+
329
+ def _store_records(log_name: str, records: list[CausalRecord]):
330
+ try:
331
+ _store.store(log_name, records)
332
+ except StoreLimitError as e:
333
+ raise HTTPException(status_code=429, detail=str(e))
334
+
335
+
336
+ # ---------------------------------------------------------------------------
337
+ # Helpers
338
+ # ---------------------------------------------------------------------------
339
+
340
+ def _parse_jsonl(text: str) -> list[CausalRecord]:
341
+ records = []
342
+ for line in text.splitlines():
343
+ line = line.strip()
344
+ if line:
345
+ try:
346
+ records.append(CausalRecord.from_json(line))
347
+ except Exception as e:
348
+ logger.exception("Failed to parse JSONL record")
349
+ raise HTTPException(
350
+ status_code=422,
351
+ detail=f"Failed to parse record: {e}\nLine: {line[:80]}"
352
+ )
353
+ return records
354
+
355
+
356
+ def _run_audit(records: list[CausalRecord], config_yaml: Optional[str] = None) -> AuditResult:
357
+ cfg = AuditConfig.from_yaml_string(config_yaml) if config_yaml else AuditConfig()
358
+ return AuditEngine(cfg).run(records)
359
+
360
+
361
+ # ---------------------------------------------------------------------------
362
+ # Models
363
+ # ---------------------------------------------------------------------------
364
+
365
+ class AuditTextRequest(BaseModel):
366
+ log: str # Raw JSONL content
367
+ config: Optional[str] = None # Optional YAML config text
368
+ format: str = "json" # json | markdown | text
369
+
370
+
371
+ class IngestRequest(BaseModel):
372
+ log_name: str
373
+ records: list[dict] # List of raw record dicts
374
+
375
+
376
+ # ---------------------------------------------------------------------------
377
+ # Routes
378
+ #
379
+ # /health is intentionally undecorated so liveness probes from load balancers
380
+ # and uptime checks cannot be rate limited. All other routes are bucketed via
381
+ # the slowapi limiter; per-route limits are configurable through env vars.
382
+ # ---------------------------------------------------------------------------
383
+
384
+ @app.get("/health")
385
+ def health():
386
+ return {"status": "ok", "version": cml.__version__}
387
+
388
+
389
+ @app.post("/audit")
390
+ @limiter.limit(_RATE_LIMIT_AUDIT)
391
+ def audit_text(request: Request, req: AuditTextRequest):
392
+ """
393
+ Audit a JSONL log provided as a string.
394
+
395
+ Returns audit result in json, markdown, or text format.
396
+ """
397
+ records = _parse_jsonl(req.log)
398
+ result = _run_audit(records, req.config)
399
+
400
+ if req.format == "markdown":
401
+ index = records_to_index(records)
402
+ return PlainTextResponse(
403
+ to_markdown(result, index=index),
404
+ media_type="text/markdown"
405
+ )
406
+ elif req.format == "text":
407
+ return PlainTextResponse(to_text(result))
408
+ else:
409
+ return JSONResponse(result.to_dict())
410
+
411
+
412
+ @app.post("/audit/file")
413
+ @limiter.limit(_RATE_LIMIT_AUDIT)
414
+ async def audit_file(request: Request, file: UploadFile = File(...)):
415
+ """
416
+ Audit an uploaded JSONL file.
417
+ """
418
+ content = await file.read()
419
+ text = content.decode("utf-8")
420
+ records = _parse_jsonl(text)
421
+ result = _run_audit(records)
422
+ return JSONResponse(result.to_dict())
423
+
424
+
425
+ @app.post("/ingest")
426
+ @limiter.limit(_RATE_LIMIT_INGEST)
427
+ def ingest(request: Request, req: IngestRequest):
428
+ """
429
+ Append records to a named in-memory log.
430
+ """
431
+ log_name = _validate_log_name(req.log_name)
432
+ records = []
433
+ for raw in req.records:
434
+ try:
435
+ records.append(CausalRecord.from_dict(raw))
436
+ except Exception as e:
437
+ logger.exception("Failed to ingest record")
438
+ raise HTTPException(status_code=422, detail=f"Invalid record: {e}")
439
+ _store_records(log_name, records)
440
+ return {"log_name": log_name, "ingested": len(records)}
441
+
442
+
443
+ @app.get("/records/{log_name}")
444
+ @limiter.limit(_RATE_LIMIT_RECORDS)
445
+ def list_records(request: Request, log_name: str):
446
+ """List all records in a named log."""
447
+ log_name = _validate_log_name(log_name)
448
+ records = _get_log(log_name)
449
+ if not records:
450
+ raise HTTPException(status_code=404, detail=f"Log '{log_name}' not found.")
451
+ return {"log_name": log_name, "count": len(records),
452
+ "records": [r.to_dict() for r in records]}
453
+
454
+
455
+ @app.get("/records/{log_name}/audit")
456
+ @limiter.limit(_RATE_LIMIT_AUDIT)
457
+ def audit_stored_log(request: Request, log_name: str):
458
+ """Run audit on a stored log."""
459
+ log_name = _validate_log_name(log_name)
460
+ records = _get_log(log_name)
461
+ if not records:
462
+ raise HTTPException(status_code=404, detail=f"Log '{log_name}' not found.")
463
+ result = _run_audit(records)
464
+ return JSONResponse(result.to_dict())
465
+
466
+
467
+ @app.get("/chain/{log_name}/{record_id}")
468
+ @limiter.limit(_RATE_LIMIT_CHAIN)
469
+ def get_chain(request: Request, log_name: str, record_id: str):
470
+ """
471
+ Reconstruct the causal chain for a record in a stored log.
472
+ """
473
+ log_name = _validate_log_name(log_name)
474
+ records = _get_log(log_name)
475
+ if not records:
476
+ raise HTTPException(status_code=404, detail=f"Log '{log_name}' not found.")
477
+ index = records_to_index(records)
478
+ if record_id not in index:
479
+ raise HTTPException(status_code=404, detail=f"Record '{record_id}' not found.")
480
+ chain = reconstruct_chain(record_id, index)
481
+ return {
482
+ "record_id": record_id,
483
+ "chain_length": len(chain),
484
+ "chain": [r.to_dict() for r in chain],
485
+ }
486
+
487
+
488
+ @app.post("/ctag/decode")
489
+ @limiter.limit(_RATE_LIMIT_CTAG)
490
+ def api_decode_ctag(request: Request, body: dict = Body(...)):
491
+ """Decode a 16-bit CTAG value."""
492
+ raw = body.get("ctag")
493
+ if raw is None:
494
+ raise HTTPException(status_code=422, detail="Field 'ctag' required.")
495
+ try:
496
+ if isinstance(raw, bool):
497
+ # bool is a subclass of int; reject explicitly to avoid surprises.
498
+ raise ValueError("ctag must be an integer or hex/decimal string")
499
+ if isinstance(raw, str):
500
+ s = raw.strip()
501
+ if not s:
502
+ raise ValueError("ctag string is empty")
503
+ val = int(s, 16) if s.lower().startswith("0x") else int(s)
504
+ elif isinstance(raw, int):
505
+ val = raw
506
+ else:
507
+ raise ValueError(f"unsupported ctag type: {type(raw).__name__}")
508
+ except (TypeError, ValueError) as e:
509
+ raise HTTPException(status_code=422, detail=f"Invalid ctag: {e}")
510
+ if not (0 <= val <= 0xFFFF):
511
+ raise HTTPException(
512
+ status_code=422,
513
+ detail="ctag out of range: must be a 16-bit value in [0, 65535].",
514
+ )
515
+ return decode_ctag(val)