openom-mcp 0.1.0__tar.gz

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.
Files changed (38) hide show
  1. openom_mcp-0.1.0/.gitignore +62 -0
  2. openom_mcp-0.1.0/PKG-INFO +112 -0
  3. openom_mcp-0.1.0/README.md +86 -0
  4. openom_mcp-0.1.0/pyproject.toml +76 -0
  5. openom_mcp-0.1.0/src/openom_mcp/__init__.py +2 -0
  6. openom_mcp-0.1.0/src/openom_mcp/apikeys.py +150 -0
  7. openom_mcp-0.1.0/src/openom_mcp/blobstore.py +180 -0
  8. openom_mcp-0.1.0/src/openom_mcp/extraction.py +30 -0
  9. openom_mcp-0.1.0/src/openom_mcp/fetch.py +174 -0
  10. openom_mcp-0.1.0/src/openom_mcp/guard.py +96 -0
  11. openom_mcp-0.1.0/src/openom_mcp/log.py +38 -0
  12. openom_mcp-0.1.0/src/openom_mcp/principal.py +23 -0
  13. openom_mcp-0.1.0/src/openom_mcp/ratelimit.py +113 -0
  14. openom_mcp-0.1.0/src/openom_mcp/redisstore.py +31 -0
  15. openom_mcp-0.1.0/src/openom_mcp/resolve.py +59 -0
  16. openom_mcp-0.1.0/src/openom_mcp/server.py +371 -0
  17. openom_mcp-0.1.0/src/openom_mcp/tools.py +449 -0
  18. openom_mcp-0.1.0/tests/test_apikeys.py +127 -0
  19. openom_mcp-0.1.0/tests/test_backends_env.py +67 -0
  20. openom_mcp-0.1.0/tests/test_blobstore.py +77 -0
  21. openom_mcp-0.1.0/tests/test_boundary.py +61 -0
  22. openom_mcp-0.1.0/tests/test_guard.py +48 -0
  23. openom_mcp-0.1.0/tests/test_http_config.py +49 -0
  24. openom_mcp-0.1.0/tests/test_http_roundtrip.py +77 -0
  25. openom_mcp-0.1.0/tests/test_limits.py +83 -0
  26. openom_mcp-0.1.0/tests/test_ratelimit.py +64 -0
  27. openom_mcp-0.1.0/tests/test_redisstore.py +53 -0
  28. openom_mcp-0.1.0/tests/test_remote.py +222 -0
  29. openom_mcp-0.1.0/tests/test_resolve.py +80 -0
  30. openom_mcp-0.1.0/tests/test_s3blobstore.py +61 -0
  31. openom_mcp-0.1.0/tests/test_s3blobstore_integration.py +65 -0
  32. openom_mcp-0.1.0/tests/test_server.py +105 -0
  33. openom_mcp-0.1.0/tests/test_ssrf.py +207 -0
  34. openom_mcp-0.1.0/tests/test_ssrf_live.py +136 -0
  35. openom_mcp-0.1.0/tests/test_tool_contract.py +40 -0
  36. openom_mcp-0.1.0/tests/test_tools.py +141 -0
  37. openom_mcp-0.1.0/tests/test_transport_security.py +64 -0
  38. openom_mcp-0.1.0/tests/test_upload.py +68 -0
@@ -0,0 +1,62 @@
1
+ # --- OpenOM repo hygiene (see CLAUDE.md Rule 2) ---
2
+
3
+ # Marketing mock - NEVER committed
4
+ OpenOM - Make your deal legible to the buy-side's AI.htm
5
+ OpenOM - Make your deal legible to the buy-side's AI_files/
6
+
7
+ # Planning scratch - git-ignored by rule
8
+ docs/superpowers/
9
+ .planning/
10
+
11
+ # Eval / tooling scratch
12
+ .deepeval/
13
+
14
+ # Real OM corpus - private test fixtures, never committed (confidential)
15
+ OMs/
16
+
17
+ # --- Python (/core, /cli, /mcp) ---
18
+ __pycache__/
19
+ *.py[cod]
20
+ *.egg-info/
21
+ .eggs/
22
+ build/
23
+ dist/
24
+ .venv/
25
+ venv/
26
+ .env
27
+ .mypy_cache/
28
+ .ruff_cache/
29
+ .pytest_cache/
30
+ .coverage
31
+ htmlcov/
32
+
33
+ # --- Node / TS (/js, /extension) ---
34
+ node_modules/
35
+ *.tsbuildinfo
36
+ .npm/
37
+ extension/src/generated/
38
+ extension/test-results/
39
+ extension/playwright-report/
40
+
41
+ # --- OS / editor ---
42
+ .DS_Store
43
+ Thumbs.db
44
+ .idea/
45
+ .vscode/
46
+ coverage/
47
+ extension/*.zip
48
+
49
+ js/test-results/
50
+
51
+ # marketing mock (Rule 2 - never commit)
52
+ *.htm
53
+ *_files/
54
+ .playwright-mcp/
55
+ om-normative-spec-draft-v0.1.md
56
+ om-standard-handoff-v4-updated.md
57
+ recon.md
58
+ openOM-decision-memo.md
59
+
60
+ # audit/backlog scratch is temp - lives in .planning/ (see Rule 2)
61
+ /BACKLOG.md
62
+ /*-audit*.md
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.5
2
+ Name: openom-mcp
3
+ Version: 0.1.0
4
+ Summary: openOM MCP server - deterministic tool surface over openom-core. Zero inference, ever.
5
+ Project-URL: Homepage, https://openom.app
6
+ Project-URL: Documentation, https://openom.app/docs/grounding-ai
7
+ Project-URL: Repository, https://github.com/Vervelio-Labs/OpenOM
8
+ License-Expression: MIT
9
+ Keywords: ai-agents,commercial-real-estate,cre,json-ld,mcp,model-context-protocol,offering-memorandum,open-standard,openom,proptech
10
+ Requires-Python: >=3.11
11
+ Requires-Dist: httpx>=0.27
12
+ Requires-Dist: mcp<3,>=2.0
13
+ Requires-Dist: openom-core[render]<0.2,>=0.1
14
+ Requires-Dist: uvicorn>=0.30
15
+ Provides-Extra: dev
16
+ Requires-Dist: boto3>=1.34; extra == 'dev'
17
+ Requires-Dist: fakeredis>=2.20; extra == 'dev'
18
+ Requires-Dist: moto[s3]>=5; extra == 'dev'
19
+ Requires-Dist: mypy>=1.10; extra == 'dev'
20
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
21
+ Requires-Dist: pytest>=8; extra == 'dev'
22
+ Requires-Dist: ruff>=0.5; extra == 'dev'
23
+ Provides-Extra: r2
24
+ Requires-Dist: boto3>=1.34; extra == 'r2'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # openom-mcp
28
+
29
+ A thin, **deterministic** [FastMCP](https://github.com/jlowin/fastmcp) server over
30
+ [`openom-core`](../core) - six data verbs, zero inference:
31
+ `om_inspect · om_extract_text · om_extract_images · om_read · om_validate · om_embed`, plus one
32
+ hosted-transport helper `om_request_upload` (reserves a presigned upload target; HTTP transport only).
33
+ So `tools/list` reports **seven** tools.
34
+ Two transports: **stdio** (`om-mcp`) and **hosted Streamable HTTP** (`om-mcp-http`, SSRF-guarded,
35
+ rate-limited, untrusted-parse-isolated).
36
+
37
+ ```sh
38
+ pip install openom-mcp # depends on openom-core (also from PyPI)
39
+ # from a clone (openom-core is not yet on PyPI): install core first, then mcp:
40
+ pip install -e ./core && pip install -e "./mcp[dev]"
41
+ ```
42
+
43
+ ## Connect it to an MCP client (stdio)
44
+
45
+ Add to your client's MCP config (e.g. Claude Desktop `claude_desktop_config.json`, or any
46
+ `mcp.json`):
47
+
48
+ ```json
49
+ {
50
+ "mcpServers": {
51
+ "openom": { "command": "om-mcp" }
52
+ }
53
+ }
54
+ ```
55
+
56
+ ## Hosted HTTP
57
+
58
+ `om-mcp-http` serves Streamable HTTP at the **`/mcp`** path (e.g. `http://127.0.0.1:8080/mcp`). By
59
+ default it binds **loopback** (`127.0.0.1:8080`) - safe out of the box, not a world-open server. The
60
+ free **public grounding endpoint** at `https://mcp.openom.app/mcp` is a serverless Cloudflare Worker
61
+ (`/mcp-worker`) exposing the read-side `om_read` + `om_validate` via the byte-parity `/js` core; run
62
+ `om-mcp-http` yourself for the full six-tool surface (extract/embed/inspect). Two-tier validation:
63
+ schema errors block, consistency warnings never do; market truth is out of scope.
64
+
65
+ **Tool inputs.** The PDF-taking tools (`om_inspect`/`om_read`/`om_extract_*`/`om_embed`) accept a
66
+ `pdf` object that is exactly one of: `{"path": "..."}` (stdio, a local file), `{"url": "https://..."}`
67
+ (HTTP transport, fetched under the SSRF rules), or `{"blobId": "..."}` (HTTP, a prior
68
+ `om_request_upload`). `om_validate` takes a `payload` object.
69
+
70
+ **Stability.** The seven-tool surface + result shapes are versioned with the spec (`specVersion`
71
+ `0.1`); the `om_read`/`om_validate` shapes match the public Worker ([Ma9]). Breaking tool-surface
72
+ changes ship under a new spec minor. See the repo `spec/CHANGELOG.md`.
73
+
74
+ ### Configuration (env)
75
+
76
+ Every knob is an environment variable; defaults are safe for local use:
77
+
78
+ | Variable | Default | Meaning |
79
+ | --- | --- | --- |
80
+ | `OPENOM_MCP_HOST` | `127.0.0.1` | Bind address. Set `0.0.0.0` to expose publicly. |
81
+ | `OPENOM_MCP_PORT` | `8080` | Bind port. |
82
+ | `OPENOM_MCP_DNS_REBINDING` | auto | Host/Origin DNS-rebinding defense. **Auto-ON when bound to a non-loopback host.** |
83
+ | `OPENOM_MCP_ALLOWED_HOSTS` | *(empty)* | Comma-separated Host allowlist (set this when binding publicly). |
84
+ | `OPENOM_MCP_ALLOWED_ORIGINS` | *(empty)* | Comma-separated Origin allowlist. |
85
+ | `OPENOM_MCP_RATE_LIMIT` / `_WINDOW` | `120` / `60` | Per-principal request cap per window (seconds). |
86
+ | `OPENOM_MCP_MAX_FETCH_BYTES` | `209715200` | Cap on fetched PDF size (SSRF/DoS guard). |
87
+ | `OPENOM_MCP_MAX_PAGES` | *(unset)* | Per-call page ceiling for extraction. |
88
+ | `OPENOM_MCP_LOG` | - | Log level. |
89
+
90
+ When you bind publicly (`0.0.0.0`) always set `OPENOM_MCP_ALLOWED_HOSTS`/`_ORIGINS` - the server logs
91
+ a warning if you don't.
92
+
93
+ **Production backends (env-selectable):**
94
+
95
+ | Variable | Default | Meaning |
96
+ | --- | --- | --- |
97
+ | `OPENOM_MCP_BLOB_BACKEND` | `local` | `r2` uses Cloudflare R2/S3 (needs `OPENOM_R2_BUCKET`, `OPENOM_R2_ENDPOINT`, `OPENOM_R2_ACCESS_KEY`, `OPENOM_R2_SECRET_KEY`; `boto3` extra). |
98
+ | `OPENOM_MCP_LIMITER` | `memory` | `redis` uses a shared Redis so multiple replicas enforce one global limit (needs `OPENOM_REDIS_URL`; a `redis`-py-compatible client). |
99
+
100
+ (Both `boto3` and `redis` are imported lazily - neither is a hard dependency.) For anything further,
101
+ `build_http_app(...)` accepts `blob_store` / `rate_limiter` injections directly.
102
+
103
+ ## Grounding an AI agent
104
+
105
+ Treat an openOM payload as the broker's **asserted opinion**, not fact: attribute every figure to
106
+ `assertedBy` as of `assertedDate`, don't use a payload whose `verification.hashValid` isn't true,
107
+ and never present an OM figure as verified truth (an OM is an advertisement / opinion of value). The
108
+ full guide - MCP config, tool usage, and a system-prompt snippet - is
109
+ **[Grounding AI agents in openOM](../site/openom/docs/grounding-ai.html)** (served at
110
+ `…/openom/docs/grounding-ai.html`).
111
+
112
+ Tests: `pytest mcp -q`.
@@ -0,0 +1,86 @@
1
+ # openom-mcp
2
+
3
+ A thin, **deterministic** [FastMCP](https://github.com/jlowin/fastmcp) server over
4
+ [`openom-core`](../core) - six data verbs, zero inference:
5
+ `om_inspect · om_extract_text · om_extract_images · om_read · om_validate · om_embed`, plus one
6
+ hosted-transport helper `om_request_upload` (reserves a presigned upload target; HTTP transport only).
7
+ So `tools/list` reports **seven** tools.
8
+ Two transports: **stdio** (`om-mcp`) and **hosted Streamable HTTP** (`om-mcp-http`, SSRF-guarded,
9
+ rate-limited, untrusted-parse-isolated).
10
+
11
+ ```sh
12
+ pip install openom-mcp # depends on openom-core (also from PyPI)
13
+ # from a clone (openom-core is not yet on PyPI): install core first, then mcp:
14
+ pip install -e ./core && pip install -e "./mcp[dev]"
15
+ ```
16
+
17
+ ## Connect it to an MCP client (stdio)
18
+
19
+ Add to your client's MCP config (e.g. Claude Desktop `claude_desktop_config.json`, or any
20
+ `mcp.json`):
21
+
22
+ ```json
23
+ {
24
+ "mcpServers": {
25
+ "openom": { "command": "om-mcp" }
26
+ }
27
+ }
28
+ ```
29
+
30
+ ## Hosted HTTP
31
+
32
+ `om-mcp-http` serves Streamable HTTP at the **`/mcp`** path (e.g. `http://127.0.0.1:8080/mcp`). By
33
+ default it binds **loopback** (`127.0.0.1:8080`) - safe out of the box, not a world-open server. The
34
+ free **public grounding endpoint** at `https://mcp.openom.app/mcp` is a serverless Cloudflare Worker
35
+ (`/mcp-worker`) exposing the read-side `om_read` + `om_validate` via the byte-parity `/js` core; run
36
+ `om-mcp-http` yourself for the full six-tool surface (extract/embed/inspect). Two-tier validation:
37
+ schema errors block, consistency warnings never do; market truth is out of scope.
38
+
39
+ **Tool inputs.** The PDF-taking tools (`om_inspect`/`om_read`/`om_extract_*`/`om_embed`) accept a
40
+ `pdf` object that is exactly one of: `{"path": "..."}` (stdio, a local file), `{"url": "https://..."}`
41
+ (HTTP transport, fetched under the SSRF rules), or `{"blobId": "..."}` (HTTP, a prior
42
+ `om_request_upload`). `om_validate` takes a `payload` object.
43
+
44
+ **Stability.** The seven-tool surface + result shapes are versioned with the spec (`specVersion`
45
+ `0.1`); the `om_read`/`om_validate` shapes match the public Worker ([Ma9]). Breaking tool-surface
46
+ changes ship under a new spec minor. See the repo `spec/CHANGELOG.md`.
47
+
48
+ ### Configuration (env)
49
+
50
+ Every knob is an environment variable; defaults are safe for local use:
51
+
52
+ | Variable | Default | Meaning |
53
+ | --- | --- | --- |
54
+ | `OPENOM_MCP_HOST` | `127.0.0.1` | Bind address. Set `0.0.0.0` to expose publicly. |
55
+ | `OPENOM_MCP_PORT` | `8080` | Bind port. |
56
+ | `OPENOM_MCP_DNS_REBINDING` | auto | Host/Origin DNS-rebinding defense. **Auto-ON when bound to a non-loopback host.** |
57
+ | `OPENOM_MCP_ALLOWED_HOSTS` | *(empty)* | Comma-separated Host allowlist (set this when binding publicly). |
58
+ | `OPENOM_MCP_ALLOWED_ORIGINS` | *(empty)* | Comma-separated Origin allowlist. |
59
+ | `OPENOM_MCP_RATE_LIMIT` / `_WINDOW` | `120` / `60` | Per-principal request cap per window (seconds). |
60
+ | `OPENOM_MCP_MAX_FETCH_BYTES` | `209715200` | Cap on fetched PDF size (SSRF/DoS guard). |
61
+ | `OPENOM_MCP_MAX_PAGES` | *(unset)* | Per-call page ceiling for extraction. |
62
+ | `OPENOM_MCP_LOG` | - | Log level. |
63
+
64
+ When you bind publicly (`0.0.0.0`) always set `OPENOM_MCP_ALLOWED_HOSTS`/`_ORIGINS` - the server logs
65
+ a warning if you don't.
66
+
67
+ **Production backends (env-selectable):**
68
+
69
+ | Variable | Default | Meaning |
70
+ | --- | --- | --- |
71
+ | `OPENOM_MCP_BLOB_BACKEND` | `local` | `r2` uses Cloudflare R2/S3 (needs `OPENOM_R2_BUCKET`, `OPENOM_R2_ENDPOINT`, `OPENOM_R2_ACCESS_KEY`, `OPENOM_R2_SECRET_KEY`; `boto3` extra). |
72
+ | `OPENOM_MCP_LIMITER` | `memory` | `redis` uses a shared Redis so multiple replicas enforce one global limit (needs `OPENOM_REDIS_URL`; a `redis`-py-compatible client). |
73
+
74
+ (Both `boto3` and `redis` are imported lazily - neither is a hard dependency.) For anything further,
75
+ `build_http_app(...)` accepts `blob_store` / `rate_limiter` injections directly.
76
+
77
+ ## Grounding an AI agent
78
+
79
+ Treat an openOM payload as the broker's **asserted opinion**, not fact: attribute every figure to
80
+ `assertedBy` as of `assertedDate`, don't use a payload whose `verification.hashValid` isn't true,
81
+ and never present an OM figure as verified truth (an OM is an advertisement / opinion of value). The
82
+ full guide - MCP config, tool usage, and a system-prompt snippet - is
83
+ **[Grounding AI agents in openOM](../site/openom/docs/grounding-ai.html)** (served at
84
+ `…/openom/docs/grounding-ai.html`).
85
+
86
+ Tests: `pytest mcp -q`.
@@ -0,0 +1,76 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "openom-mcp"
7
+ version = "0.1.0"
8
+ description = "openOM MCP server - deterministic tool surface over openom-core. Zero inference, ever."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ keywords = [
13
+ "openom", "mcp", "model-context-protocol", "offering-memorandum", "commercial-real-estate",
14
+ "cre", "proptech", "json-ld", "open-standard", "ai-agents",
15
+ ]
16
+ # openom-core pinned to the same 0.x spec line (SemVer: a 0.2 core may change the payload contract).
17
+ dependencies = ["openom-core[render]>=0.1,<0.2", "mcp>=2.0,<3", "httpx>=0.27", "uvicorn>=0.30"]
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "pytest>=8", "pytest-cov>=5", "mypy>=1.10", "ruff>=0.5", "boto3>=1.34", "moto[s3]>=5",
22
+ "fakeredis>=2.20", # exercise RedisCounterStore without a live server (#51/#52)
23
+ ]
24
+ r2 = ["boto3>=1.34"] # S3BlobStore (R2) adapter - runtime extra for a real deployment
25
+
26
+ [project.urls]
27
+ Homepage = "https://openom.app"
28
+ Documentation = "https://openom.app/docs/grounding-ai"
29
+ Repository = "https://github.com/Vervelio-Labs/OpenOM"
30
+
31
+ [project.scripts]
32
+ om-mcp = "openom_mcp.server:main"
33
+ om-mcp-http = "openom_mcp.server:main_http"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/openom_mcp"]
37
+
38
+ [tool.ruff]
39
+ line-length = 100
40
+ src = ["src", "tests"]
41
+
42
+ [tool.ruff.lint]
43
+ select = ["E", "F", "I", "B", "UP", "W"]
44
+
45
+ [tool.mypy]
46
+ python_version = "3.11"
47
+ strict = true
48
+ files = ["src"]
49
+ # The MCP server SDK exposes an untyped @tool() decorator; the tool fns themselves are fully typed.
50
+ disallow_untyped_decorators = false
51
+ # boto3/botocore ship no py.typed; when installed (CI) mypy emits import-untyped attributed to the
52
+ # IMPORTING file (openom_mcp.blobstore), so it must be disabled globally, not on the boto3 override.
53
+ disable_error_code = ["import-untyped"]
54
+
55
+ [[tool.mypy.overrides]]
56
+ # Third-party SDK + OPTIONAL runtime deps (boto3/uvicorn/redis are lazy extras, absent in the base
57
+ # env). ignore_missing_imports keeps mypy green whether or not they're installed - no per-line,
58
+ # env-fragile `type: ignore` comments needed.
59
+ module = [
60
+ "mcp", "mcp.*", "pymupdf", "pymupdf.*",
61
+ "boto3", "botocore", "botocore.*", "uvicorn", "redis",
62
+ ]
63
+ ignore_missing_imports = true
64
+ follow_imports = "skip"
65
+
66
+ [tool.pytest.ini_options]
67
+ testpaths = ["tests"]
68
+ addopts = "-ra"
69
+
70
+ [tool.coverage.run]
71
+ source = ["openom_mcp"]
72
+ branch = true
73
+
74
+ [tool.coverage.report]
75
+ show_missing = true
76
+ exclude_lines = ["pragma: no cover", "if __name__"]
@@ -0,0 +1,2 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """openOM MCP server (spec §I) over the deterministic core. Zero inference, ever."""
@@ -0,0 +1,150 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """API-key lifecycle for the hosted transport (#52): issue/verify/rotate/revoke + per-key quota.
3
+
4
+ Deterministic logic over a pluggable ``KeyStore`` (+ the ``CounterStore`` seam from ratelimit.py for
5
+ quota accounting). The hosted deploy binds a real KV/DB + Redis; ``InMemoryKeyStore`` is the
6
+ test/self-host impl. No inference - this is the paid instance's access layer over the
7
+ same deterministic engine.
8
+
9
+ Security posture: the plaintext key (``omk_<token>``) is shown EXACTLY ONCE, at issue; only its
10
+ SHA-256 hash is ever stored or logged. Verification is hash-lookup + status check; rotation issues a
11
+ new key and revokes the old; revocation is immediate. Quota is a per-key volume cap over a window,
12
+ distinct from the short-window burst rate limit.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import secrets
19
+ import time
20
+ from collections.abc import Callable
21
+ from dataclasses import dataclass, replace
22
+ from typing import Literal, Protocol
23
+
24
+ from .ratelimit import CounterStore
25
+ from .tools import ToolError
26
+
27
+ KEY_PREFIX = "omk_"
28
+ KeyStatus = Literal["active", "revoked"]
29
+
30
+
31
+ def hash_key(plaintext: str) -> str:
32
+ """The stored/loggable identifier for a key: its SHA-256 hex. Never store the plaintext."""
33
+ return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ApiKeyRecord:
38
+ key_id: str # public, non-secret handle (safe to show in a dashboard/logs)
39
+ key_hash: str # sha256(plaintext); the lookup key
40
+ owner: str # principal that owns the key
41
+ created: float
42
+ status: KeyStatus = "active"
43
+ quota_limit: int = 0 # max calls per quota window; 0 = unlimited
44
+ quota_window_seconds: int = 86_400 # default: per day
45
+ revoked_at: float | None = None
46
+
47
+
48
+ class KeyStore(Protocol):
49
+ """Persistence seam. The hosted deploy backs this with a KV/DB; tests use InMemoryKeyStore."""
50
+
51
+ def get_by_hash(self, key_hash: str) -> ApiKeyRecord | None: ...
52
+ def get_by_id(self, key_id: str) -> ApiKeyRecord | None: ...
53
+ def put(self, record: ApiKeyRecord) -> None: ...
54
+
55
+
56
+ class InMemoryKeyStore:
57
+ """Single-process KeyStore for tests / self-host."""
58
+
59
+ def __init__(self) -> None:
60
+ self._by_hash: dict[str, ApiKeyRecord] = {}
61
+ self._by_id: dict[str, ApiKeyRecord] = {}
62
+
63
+ def get_by_hash(self, key_hash: str) -> ApiKeyRecord | None:
64
+ return self._by_hash.get(key_hash)
65
+
66
+ def get_by_id(self, key_id: str) -> ApiKeyRecord | None:
67
+ return self._by_id.get(key_id)
68
+
69
+ def put(self, record: ApiKeyRecord) -> None:
70
+ self._by_hash[record.key_hash] = record
71
+ self._by_id[record.key_id] = record
72
+
73
+
74
+ class ApiKeyManager:
75
+ """Issue / verify / rotate / revoke API keys and enforce per-key quota."""
76
+
77
+ def __init__(
78
+ self,
79
+ store: KeyStore,
80
+ counters: CounterStore,
81
+ *,
82
+ now: Callable[[], float] = time.time,
83
+ rng: Callable[[], str] = lambda: secrets.token_urlsafe(24),
84
+ ) -> None:
85
+ self.store = store
86
+ self.counters = counters
87
+ self.now = now
88
+ self._rng = rng # injectable for deterministic tests
89
+
90
+ def issue(
91
+ self, owner: str, *, quota_limit: int = 0, quota_window_seconds: int = 86_400
92
+ ) -> tuple[str, ApiKeyRecord]:
93
+ """Mint a new key for ``owner``. Returns (plaintext_shown_once, record). Store keeps only
94
+ the hash - the plaintext is unrecoverable after this call."""
95
+ plaintext = KEY_PREFIX + self._rng()
96
+ record = ApiKeyRecord(
97
+ key_id="k_" + secrets.token_hex(8),
98
+ key_hash=hash_key(plaintext),
99
+ owner=owner,
100
+ created=self.now(),
101
+ quota_limit=quota_limit,
102
+ quota_window_seconds=quota_window_seconds,
103
+ )
104
+ self.store.put(record)
105
+ return plaintext, record
106
+
107
+ def verify(self, plaintext: str) -> ApiKeyRecord | None:
108
+ """Return the ACTIVE record for a plaintext key, or None (unknown / revoked / malformed)."""
109
+ if not plaintext.startswith(KEY_PREFIX):
110
+ return None
111
+ record = self.store.get_by_hash(hash_key(plaintext))
112
+ if record is None or record.status != "active":
113
+ return None
114
+ return record
115
+
116
+ def revoke(self, key_id: str) -> bool:
117
+ """Immediately deactivate a key. Returns True if a key was revoked, False if unknown."""
118
+ record = self.store.get_by_id(key_id)
119
+ if record is None or record.status == "revoked":
120
+ return False
121
+ self.store.put(replace(record, status="revoked", revoked_at=self.now()))
122
+ return True
123
+
124
+ def rotate(self, key_id: str) -> tuple[str, ApiKeyRecord] | None:
125
+ """Issue a replacement key for the same owner (same quota policy) and revoke the old one.
126
+ Returns the new (plaintext, record), or None if ``key_id`` is unknown."""
127
+ old = self.store.get_by_id(key_id)
128
+ if old is None:
129
+ return None
130
+ new = self.issue(
131
+ old.owner,
132
+ quota_limit=old.quota_limit,
133
+ quota_window_seconds=old.quota_window_seconds,
134
+ )
135
+ self.revoke(key_id)
136
+ return new
137
+
138
+ def check_quota(self, record: ApiKeyRecord) -> None:
139
+ """Count one call against the key's quota window; raise OM-IO-014 when the cap is exceeded.
140
+ A key with ``quota_limit == 0`` is unlimited (only the burst rate limit applies)."""
141
+ if record.quota_limit <= 0:
142
+ return
143
+ window = record.quota_window_seconds
144
+ idx = int(self.now() // window)
145
+ count = self.counters.incr(f"quota:{record.key_id}:{idx}", window)
146
+ if count > record.quota_limit:
147
+ retry = max(1, int(window - (self.now() % window)))
148
+ raise ToolError(
149
+ "OM-IO-014", "quota exceeded", retryable=True, retry_after=retry
150
+ )
@@ -0,0 +1,180 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Blob storage for the hosted transport (spec §K, [OM-SEC-006/013], Q4 retention).
3
+
4
+ Uploaded/produced PDFs live as short-lived blobs (≤24h TTL + delete-on-completion). ``BlobStore``
5
+ is the interface; ``LocalBlobStore`` (tmpdir + an injected clock) proves the TTL/delete/authz
6
+ semantics offline for the gate. ``S3BlobStore`` (the R2 adapter, Task 8) implements the same
7
+ interface for production. Zero inference, zero network in this module's tested path.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import secrets
13
+ import time
14
+ from abc import ABC, abstractmethod
15
+ from collections.abc import Callable
16
+ from datetime import UTC, datetime
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from .tools import ToolError
21
+
22
+
23
+ def new_blob_id() -> str:
24
+ """An unguessable blob id (≥128 bits of entropy, URL-safe) ([OM-SEC-013])."""
25
+ return secrets.token_urlsafe(32)
26
+
27
+
28
+ def _iso(epoch: float) -> str:
29
+ return datetime.fromtimestamp(epoch, tz=UTC).isoformat()
30
+
31
+
32
+ class BlobStore(ABC):
33
+ """Single-tenant, TTL-bounded blob storage keyed by an unguessable ``blobId``."""
34
+
35
+ @abstractmethod
36
+ def create_upload(self, principal: str) -> dict[str, str]:
37
+ """Reserve a blob and return ``{blobId, presignedPut, expiresAt}`` for a client upload."""
38
+
39
+ @abstractmethod
40
+ def get(self, blob_id: str, principal: str) -> bytes:
41
+ """Return the blob bytes, or raise OM-IO-006 (missing/expired) / OM-IO-007 (authz)."""
42
+
43
+ @abstractmethod
44
+ def put_result(self, data: bytes, principal: str) -> dict[str, str]:
45
+ """Store produced bytes; return ``{blobId, presignedGet, expiresAt}``."""
46
+
47
+ @abstractmethod
48
+ def delete(self, blob_id: str) -> None:
49
+ """Remove a blob (idempotent)."""
50
+
51
+
52
+ class LocalBlobStore(BlobStore):
53
+ """Filesystem-backed store for tests/self-host. Presigned URLs are loopback stubs."""
54
+
55
+ def __init__(
56
+ self, root: Path, *, ttl_seconds: int = 86400, now: Callable[[], float] = time.time
57
+ ) -> None:
58
+ self.root = Path(root)
59
+ self.root.mkdir(parents=True, exist_ok=True)
60
+ self.ttl = ttl_seconds
61
+ self.now = now
62
+ self._meta: dict[str, dict[str, float | str]] = {}
63
+
64
+ def _record(self, blob_id: str, principal: str) -> str:
65
+ created = self.now()
66
+ self._meta[blob_id] = {"owner": principal, "created": created}
67
+ return _iso(created + self.ttl)
68
+
69
+ def create_upload(self, principal: str) -> dict[str, str]:
70
+ blob_id = new_blob_id()
71
+ expires = self._record(blob_id, principal)
72
+ return {"blobId": blob_id, "presignedPut": f"local://put/{blob_id}", "expiresAt": expires}
73
+
74
+ def put_result(self, data: bytes, principal: str) -> dict[str, str]:
75
+ blob_id = new_blob_id()
76
+ expires = self._record(blob_id, principal)
77
+ (self.root / blob_id).write_bytes(data)
78
+ return {"blobId": blob_id, "presignedGet": f"local://get/{blob_id}", "expiresAt": expires}
79
+
80
+ def get(self, blob_id: str, principal: str) -> bytes:
81
+ meta = self._meta.get(blob_id)
82
+ path = self.root / blob_id
83
+ if meta is None or not path.exists():
84
+ raise ToolError("OM-IO-006", "blobId not found or expired")
85
+ if self.now() - float(meta["created"]) > self.ttl: # TTL backstop
86
+ self.delete(blob_id)
87
+ raise ToolError("OM-IO-006", "blobId not found or expired")
88
+ if meta["owner"] != principal: # existence-then-authz (anti-IDOR, OM-SEC-013)
89
+ raise ToolError("OM-IO-007", "blobId not authorized for this principal")
90
+ return path.read_bytes()
91
+
92
+ def delete(self, blob_id: str) -> None:
93
+ self._meta.pop(blob_id, None)
94
+ (self.root / blob_id).unlink(missing_ok=True)
95
+
96
+
97
+ class S3BlobStore(BlobStore):
98
+ """R2/S3 adapter with **server-bound ownership** (#50). Ownership is recorded by a companion
99
+ ``_owners/<blobId>`` object that ONLY this server writes - never derived from client-supplied
100
+ object metadata - so a presigned PUT (which can write only the data object) cannot forge or
101
+ omit ownership. ``get`` reads the server-written owner for authz; TTL is enforced both by a
102
+ ≤24h bucket lifecycle rule and from the object's ``LastModified``. boto3 is an optional extra;
103
+ inject ``client`` for tests. authz/TTL semantics match ``LocalBlobStore``.
104
+ """
105
+
106
+ def __init__(
107
+ self, *, bucket: str, endpoint_url: str = "", access_key: str = "", secret_key: str = "",
108
+ ttl_seconds: int = 86400, client: Any = None,
109
+ ) -> None:
110
+ self.bucket = bucket
111
+ self.ttl = ttl_seconds
112
+ self.s3 = client if client is not None else self._make_client(
113
+ endpoint_url, access_key, secret_key
114
+ )
115
+
116
+ @staticmethod
117
+ def _make_client( # pragma: no cover - real boto3 client; moto tests inject a client
118
+ endpoint_url: str, access_key: str, secret_key: str
119
+ ) -> Any:
120
+ import boto3
121
+
122
+ return boto3.client(
123
+ "s3", endpoint_url=endpoint_url,
124
+ aws_access_key_id=access_key, aws_secret_access_key=secret_key,
125
+ )
126
+
127
+ def _expires(self) -> str:
128
+ return _iso(time.time() + self.ttl)
129
+
130
+ @staticmethod
131
+ def _owner_key(blob_id: str) -> str:
132
+ return f"_owners/{blob_id}"
133
+
134
+ def _bind_owner(self, blob_id: str, principal: str) -> None:
135
+ # Server-written, not client-controlled: the presigned PUT covers only the data key.
136
+ self.s3.put_object(
137
+ Bucket=self.bucket, Key=self._owner_key(blob_id), Body=principal.encode()
138
+ )
139
+
140
+ def create_upload(self, principal: str) -> dict[str, str]:
141
+ blob_id = new_blob_id()
142
+ self._bind_owner(blob_id, principal)
143
+ url = self.s3.generate_presigned_url(
144
+ "put_object", Params={"Bucket": self.bucket, "Key": blob_id},
145
+ ExpiresIn=min(self.ttl, 3600),
146
+ )
147
+ return {"blobId": blob_id, "presignedPut": url, "expiresAt": self._expires()}
148
+
149
+ def put_result(self, data: bytes, principal: str) -> dict[str, str]:
150
+ blob_id = new_blob_id()
151
+ self._bind_owner(blob_id, principal)
152
+ self.s3.put_object(Bucket=self.bucket, Key=blob_id, Body=data)
153
+ url = self.s3.generate_presigned_url(
154
+ "get_object", Params={"Bucket": self.bucket, "Key": blob_id},
155
+ ExpiresIn=min(self.ttl, 3600),
156
+ )
157
+ return {"blobId": blob_id, "presignedGet": url, "expiresAt": self._expires()}
158
+
159
+ def get(self, blob_id: str, principal: str) -> bytes:
160
+ from botocore.exceptions import ClientError
161
+
162
+ try: # authz first, from the server-written owner record (anti-IDOR, anti-forge)
163
+ owner = self.s3.get_object(Bucket=self.bucket, Key=self._owner_key(blob_id))
164
+ except ClientError as exc:
165
+ raise ToolError("OM-IO-006", "blobId not found or expired") from exc
166
+ if owner["Body"].read().decode() != principal:
167
+ raise ToolError("OM-IO-007", "blobId not authorized for this principal")
168
+ try:
169
+ obj = self.s3.get_object(Bucket=self.bucket, Key=blob_id)
170
+ except ClientError as exc:
171
+ raise ToolError("OM-IO-006", "blobId not found or expired") from exc
172
+ if time.time() - obj["LastModified"].timestamp() > self.ttl:
173
+ self.delete(blob_id)
174
+ raise ToolError("OM-IO-006", "blobId not found or expired")
175
+ body: bytes = obj["Body"].read()
176
+ return body
177
+
178
+ def delete(self, blob_id: str) -> None:
179
+ self.s3.delete_object(Bucket=self.bucket, Key=blob_id)
180
+ self.s3.delete_object(Bucket=self.bucket, Key=self._owner_key(blob_id))