puremethod 0.1.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.
Files changed (171) hide show
  1. app/__init__.py +2 -0
  2. app/__main__.py +31 -0
  3. app/_web/404/index.html +1 -0
  4. app/_web/404.html +1 -0
  5. app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
  6. app/_web/__next._full.txt +36 -0
  7. app/_web/__next._tree.txt +4 -0
  8. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
  9. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
  10. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
  11. app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
  12. app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
  13. app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  14. app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
  15. app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
  16. app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
  17. app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
  18. app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
  19. app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
  20. app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
  21. app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
  22. app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
  23. app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
  24. app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
  25. app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
  26. app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
  27. app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
  28. app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
  29. app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
  30. app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
  31. app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
  32. app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
  33. app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
  34. app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
  35. app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
  36. app/_web/_not-found/__next._full.txt +24 -0
  37. app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
  38. app/_web/_not-found/__next._tree.txt +4 -0
  39. app/_web/_not-found/index.html +1 -0
  40. app/_web/_not-found/index.txt +24 -0
  41. app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
  42. app/_web/credentials/__next._full.txt +39 -0
  43. app/_web/credentials/__next._tree.txt +5 -0
  44. app/_web/credentials/index.html +1 -0
  45. app/_web/credentials/index.txt +39 -0
  46. app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
  47. app/_web/executors/__next._full.txt +39 -0
  48. app/_web/executors/__next._tree.txt +5 -0
  49. app/_web/executors/index.html +1 -0
  50. app/_web/executors/index.txt +39 -0
  51. app/_web/index.html +1 -0
  52. app/_web/index.txt +36 -0
  53. app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
  54. app/_web/new/__next._full.txt +39 -0
  55. app/_web/new/__next._tree.txt +5 -0
  56. app/_web/new/index.html +1 -0
  57. app/_web/new/index.txt +39 -0
  58. app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
  59. app/_web/profiles/__next._full.txt +39 -0
  60. app/_web/profiles/__next._tree.txt +5 -0
  61. app/_web/profiles/index.html +1 -0
  62. app/_web/profiles/index.txt +39 -0
  63. app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
  64. app/_web/run/__next._full.txt +40 -0
  65. app/_web/run/__next._tree.txt +5 -0
  66. app/_web/run/index.html +1 -0
  67. app/_web/run/index.txt +40 -0
  68. app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
  69. app/_web/services/__next._full.txt +39 -0
  70. app/_web/services/__next._tree.txt +5 -0
  71. app/_web/services/index.html +1 -0
  72. app/_web/services/index.txt +39 -0
  73. app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
  74. app/_web/setup/__next._full.txt +39 -0
  75. app/_web/setup/__next._tree.txt +5 -0
  76. app/_web/setup/index.html +1 -0
  77. app/_web/setup/index.txt +39 -0
  78. app/_web/signin/__next._full.txt +30 -0
  79. app/_web/signin/__next._tree.txt +4 -0
  80. app/_web/signin/__next.signin.__PAGE__.txt +36 -0
  81. app/_web/signin/index.html +1 -0
  82. app/_web/signin/index.txt +30 -0
  83. app/accounts.py +137 -0
  84. app/addresses.py +30 -0
  85. app/api/__init__.py +61 -0
  86. app/api/auth_routes.py +54 -0
  87. app/api/common.py +102 -0
  88. app/api/config_routes.py +562 -0
  89. app/api/links.py +22 -0
  90. app/api/run_routes.py +311 -0
  91. app/api/setup_routes.py +267 -0
  92. app/bundled.py +521 -0
  93. app/compose.py +229 -0
  94. app/db.py +186 -0
  95. app/executor_config.py +95 -0
  96. app/keys.py +29 -0
  97. app/launcher.py +389 -0
  98. app/leases.py +40 -0
  99. app/literature_sources.py +131 -0
  100. app/model_endpoints.py +104 -0
  101. app/model_search.py +252 -0
  102. app/outputs.py +77 -0
  103. app/paths.py +93 -0
  104. app/readonly.py +70 -0
  105. app/repo.py +746 -0
  106. app/scan.py +65 -0
  107. app/server.py +24 -0
  108. app/settings.py +125 -0
  109. app/setup.py +117 -0
  110. app/ssh_chain.py +102 -0
  111. app/ssh_hosts.py +244 -0
  112. app/ssh_machines.py +101 -0
  113. app/statuses.py +4 -0
  114. app/supervisor.py +87 -0
  115. app/tunnels.py +222 -0
  116. app/unseal.py +27 -0
  117. app/worker.py +657 -0
  118. executor/__init__.py +47 -0
  119. executor/base.py +161 -0
  120. executor/common.py +139 -0
  121. executor/local_docker.py +239 -0
  122. executor/local_process.py +247 -0
  123. executor/ssh.py +481 -0
  124. executor/types.py +128 -0
  125. executor/watchdog.py +108 -0
  126. ledger/__init__.py +46 -0
  127. ledger/artifacts.py +62 -0
  128. ledger/canonical.py +48 -0
  129. ledger/core.py +337 -0
  130. ledger/errors.py +36 -0
  131. ledger/model.py +107 -0
  132. ledger/payloads.py +228 -0
  133. ledger/rules.py +204 -0
  134. ledger/storage.py +226 -0
  135. llm/__init__.py +45 -0
  136. llm/backends/__init__.py +0 -0
  137. llm/backends/anthropic_backend.py +145 -0
  138. llm/backends/base.py +87 -0
  139. llm/backends/openai_backend.py +211 -0
  140. llm/core.py +416 -0
  141. llm/prices.py +98 -0
  142. llm/tokens.py +18 -0
  143. llm/types.py +106 -0
  144. loop/__init__.py +19 -0
  145. loop/config.py +108 -0
  146. loop/core.py +1160 -0
  147. loop/decide.py +61 -0
  148. loop/harness.py +132 -0
  149. loop/prompts.py +88 -0
  150. loop/view.py +202 -0
  151. puremethod-0.1.0.dist-info/METADATA +79 -0
  152. puremethod-0.1.0.dist-info/RECORD +171 -0
  153. puremethod-0.1.0.dist-info/WHEEL +4 -0
  154. puremethod-0.1.0.dist-info/entry_points.txt +2 -0
  155. puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
  156. puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
  157. purescholar/__init__.py +43 -0
  158. purescholar/client.py +380 -0
  159. purescholar/types.py +164 -0
  160. puretrace/__init__.py +49 -0
  161. puretrace/client.py +320 -0
  162. puretrace/types.py +166 -0
  163. writer/__init__.py +8 -0
  164. writer/core.py +430 -0
  165. writer/facts.py +162 -0
  166. writer/latex.py +62 -0
  167. writer/lint.py +85 -0
  168. writer/macros.py +114 -0
  169. writer/render.py +120 -0
  170. writer/tectonic.py +79 -0
  171. writer/types.py +61 -0
app/accounts.py ADDED
@@ -0,0 +1,137 @@
1
+ """Tenants, sign-in and sessions (08-app.md §4, §5 step 9).
2
+
3
+ Sign-in is behind `AuthProvider`: self-hosted uses `LocalTokenAuth`; hosted mode adds OIDC
4
+ as another provider. Sessions are the same for both. Only SHA-256 hashes of tokens and
5
+ session ids are stored."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import hmac
11
+ import secrets
12
+ import time
13
+ from collections.abc import Callable
14
+ from typing import Protocol
15
+
16
+ from app.db import Database
17
+ from app.repo import new_id, now_iso
18
+
19
+ TOKEN_BYTES = 32 # 256-bit (KB-021)
20
+ SESSION_TTL_S = 12 * 3600
21
+ CODE_TTL_S = 120.0 # one-time sign-in codes (08-app.md §5 step 9, INV-9)
22
+
23
+
24
+ def _hash(secret: str) -> str:
25
+ return hashlib.sha256(secret.encode()).hexdigest()
26
+
27
+
28
+ class AuthProvider(Protocol):
29
+ def tenant_for(self, credential: str) -> str | None: ...
30
+
31
+
32
+ class LocalTokenAuth:
33
+ """Self-hosted API clients: a bearer token, issued by `token rotate` (KB-021, KB-025)."""
34
+
35
+ def __init__(self, db: Database) -> None:
36
+ self._db = db
37
+
38
+ def tenant_for(self, credential: str) -> str | None:
39
+ if not credential:
40
+ return None
41
+ with self._db.tx() as c:
42
+ rows = c.execute("SELECT tenant_id FROM tokens WHERE hash = ?", (_hash(credential),))
43
+ return str(rows[0][0]) if rows else None
44
+
45
+
46
+ def create_tenant(db: Database) -> str:
47
+ tid = new_id("t")
48
+ with db.tx(write=True) as c:
49
+ c.execute("INSERT INTO tenants VALUES (?, ?)", (tid, now_iso()))
50
+ return tid
51
+
52
+
53
+ def issue_token(db: Database, tenant_id: str) -> str:
54
+ """Replaces the tenant's tokens and ends its sessions. Returns the new token once."""
55
+ token = secrets.token_urlsafe(TOKEN_BYTES)
56
+ with db.tx(write=True) as c:
57
+ c.execute("DELETE FROM tokens WHERE tenant_id = ?", (tenant_id,))
58
+ c.execute("DELETE FROM sessions WHERE tenant_id = ?", (tenant_id,))
59
+ c.execute("INSERT INTO tokens VALUES (?, ?, ?)", (_hash(token), tenant_id, now_iso()))
60
+ return token
61
+
62
+
63
+ def tenants(db: Database) -> list[str]:
64
+ with db.tx() as c:
65
+ return [str(r[0]) for r in c.execute("SELECT id FROM tenants ORDER BY created_at, id")]
66
+
67
+
68
+ def bootstrap(db: Database) -> tuple[str, str | None]:
69
+ """Self-hosted first start: one tenant and its API token. Later starts return no token.
70
+ The caller does not print it (KB-025): `token rotate` issues one for API clients."""
71
+ existing = tenants(db)
72
+ if existing:
73
+ return existing[0], None
74
+ tid = create_tenant(db)
75
+ return tid, issue_token(db, tid)
76
+
77
+
78
+ class SigninCodes:
79
+ """One-time browser sign-in codes (08-app.md §5 step 9, INV-9): 256-bit, stored as their
80
+ SHA-256 only, valid once within 2 minutes. Redeeming deletes the row in the same write
81
+ transaction, so two requests with one code cannot both succeed."""
82
+
83
+ def __init__(self, db: Database, clock: Callable[[], float] = time.time) -> None:
84
+ self._db = db
85
+ self._clock = clock
86
+
87
+ def issue(self, tenant_id: str) -> str:
88
+ code = secrets.token_urlsafe(TOKEN_BYTES)
89
+ with self._db.tx(write=True) as c:
90
+ c.execute("DELETE FROM signin_codes WHERE expires_at < ?", (self._clock(),))
91
+ c.execute("INSERT INTO signin_codes VALUES (?, ?, ?)",
92
+ (_hash(code), tenant_id, self._clock() + CODE_TTL_S))
93
+ return code
94
+
95
+ def redeem(self, code: str) -> str | None:
96
+ if not code:
97
+ return None
98
+ with self._db.tx(write=True) as c:
99
+ rows = c.execute("SELECT tenant_id, expires_at FROM signin_codes WHERE hash = ?",
100
+ (_hash(code),))
101
+ c.execute("DELETE FROM signin_codes WHERE hash = ? OR expires_at < ?",
102
+ (_hash(code), self._clock()))
103
+ if not rows or float(rows[0][1]) < self._clock():
104
+ return None
105
+ return str(rows[0][0])
106
+
107
+
108
+ class Sessions:
109
+ def __init__(self, db: Database, clock: Callable[[], float] = time.time) -> None:
110
+ self._db = db
111
+ self._clock = clock
112
+
113
+ def open(self, tenant_id: str) -> str:
114
+ sid = secrets.token_urlsafe(TOKEN_BYTES)
115
+ with self._db.tx(write=True) as c:
116
+ c.execute("DELETE FROM sessions WHERE expires_at < ?", (self._clock(),))
117
+ c.execute("INSERT INTO sessions VALUES (?, ?, ?)",
118
+ (_hash(sid), tenant_id, self._clock() + SESSION_TTL_S))
119
+ return sid
120
+
121
+ def tenant_for(self, session_id: str) -> str | None:
122
+ if not session_id:
123
+ return None
124
+ with self._db.tx() as c:
125
+ rows = c.execute("SELECT tenant_id, expires_at FROM sessions WHERE hash = ?",
126
+ (_hash(session_id),))
127
+ if not rows or float(rows[0][1]) < self._clock():
128
+ return None
129
+ return str(rows[0][0])
130
+
131
+ def close(self, session_id: str) -> None:
132
+ with self._db.tx(write=True) as c:
133
+ c.execute("DELETE FROM sessions WHERE hash = ?", (_hash(session_id),))
134
+
135
+
136
+ def same(a: str, b: str) -> bool:
137
+ return hmac.compare_digest(a.encode(), b.encode())
app/addresses.py ADDED
@@ -0,0 +1,30 @@
1
+ """Whether a host is a private address (08-app.md INV-11, KB-036). `llm` keeps its own copy
2
+ for its call-time check; `app` uses this one, since the function is not part of `llm`'s
3
+ declared interface (07-llm.md §3)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import ipaddress
8
+ import socket
9
+
10
+
11
+ def private_address(host: str) -> bool:
12
+ """True if `host` is, or resolves to, a loopback, link-local, private or otherwise
13
+ non-public address (IPv4, IPv6, IPv4-mapped). An unresolvable name counts as private:
14
+ refusing is the safe answer."""
15
+ name = host.strip("[]")
16
+ try:
17
+ addrs = [ipaddress.ip_address(name)]
18
+ except ValueError:
19
+ try:
20
+ infos = socket.getaddrinfo(name, None)
21
+ except OSError:
22
+ return True
23
+ addrs = [ipaddress.ip_address(str(i[4][0]).split("%")[0]) for i in infos]
24
+ for a in addrs:
25
+ mapped = getattr(a, "ipv4_mapped", None)
26
+ if mapped is not None:
27
+ a = mapped
28
+ if not a.is_global or a.is_multicast:
29
+ return True
30
+ return not addrs
app/api/__init__.py ADDED
@@ -0,0 +1,61 @@
1
+ """The engine API, `/api/v1` (08-app.md §3). Never decrypts and never writes the ledger
2
+ (INV-2, INV-3): import-linter keeps `app.unseal`, `app.compose`, `app.worker`, `app.scan`,
3
+ `app.leases` and `app.supervisor` out of this package, and it sees the ledger only through
4
+ `ReadOnlyLedger` (built in `app.server`)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Awaitable, Callable
9
+ from pathlib import Path
10
+
11
+ from fastapi import FastAPI, Request, Response
12
+ from fastapi.exceptions import RequestValidationError
13
+ from fastapi.responses import JSONResponse
14
+ from fastapi.staticfiles import StaticFiles
15
+
16
+ from app.api import auth_routes, config_routes, run_routes, setup_routes
17
+ from app.api.common import Ctx, validation_errors
18
+ from app.setup import LocalStatus, local_not_yet
19
+
20
+ PREFIX = "/api/v1"
21
+ WRITE_METHODS = frozenset({"POST", "PATCH", "PUT"})
22
+ # The UI is a static export served from this origin; Next.js inlines its bootstrap scripts.
23
+ SECURITY_HEADERS = {
24
+ "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; "
25
+ "style-src 'self' 'unsafe-inline'; img-src 'self' data:; "
26
+ "connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; "
27
+ "form-action 'self'",
28
+ "X-Content-Type-Options": "nosniff",
29
+ "X-Frame-Options": "DENY",
30
+ "Referrer-Policy": "no-referrer",
31
+ }
32
+
33
+
34
+ def create_app(ctx: Ctx, web_dir: Path | None = None,
35
+ local_status: LocalStatus = local_not_yet) -> FastAPI:
36
+ """The API under `/api/v1` and, when `web_dir` holds the built web UI, the UI at `/`
37
+ from the same origin (08-app.md §5 step 8: self-hosted is one process)."""
38
+ app = FastAPI(title="puremethod engine API", docs_url=None, redoc_url=None,
39
+ openapi_url=f"{PREFIX}/openapi.json")
40
+ app.state.ctx = ctx
41
+ app.state.local_status = local_status # the bundled PureScholar's state (08 §5 step 12)
42
+ app.add_exception_handler(RequestValidationError, validation_errors)
43
+
44
+ @app.middleware("http")
45
+ async def json_bodies_only(request: Request,
46
+ call_next: Callable[[Request], Awaitable[Response]]) -> Response:
47
+ # With SameSite=Strict cookies, this blocks form posts from other sites (CSRF).
48
+ if request.method in WRITE_METHODS and request.url.path.startswith(PREFIX):
49
+ if not request.headers.get("content-type", "").startswith("application/json"):
50
+ return JSONResponse(status_code=415, content={"detail": "JSON bodies only"})
51
+ response = await call_next(request)
52
+ for name, value in SECURITY_HEADERS.items():
53
+ response.headers.setdefault(name, value)
54
+ return response
55
+
56
+ for router in (auth_routes.router, config_routes.router, run_routes.router,
57
+ setup_routes.router):
58
+ app.include_router(router, prefix=PREFIX)
59
+ if web_dir is not None and (web_dir / "index.html").is_file():
60
+ app.mount("/", StaticFiles(directory=web_dir, html=True), name="web")
61
+ return app
app/api/auth_routes.py ADDED
@@ -0,0 +1,54 @@
1
+ """Sign-in (08-app.md §5 step 9): an API token or a one-time sign-in code is exchanged for an
2
+ HttpOnly, SameSite=Strict session cookie."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Annotated
7
+
8
+ from fastapi import APIRouter, HTTPException, Request, Response
9
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
10
+
11
+ from app.accounts import SESSION_TTL_S
12
+ from app.api.common import COOKIE, ctx
13
+
14
+ router = APIRouter()
15
+ Secret = Annotated[SecretStr, Field(min_length=1, max_length=512)]
16
+
17
+
18
+ class SessionIn(BaseModel):
19
+ """Exactly one of `token` (the API token) or `code` (a one-time code from the launcher)."""
20
+ model_config = ConfigDict(extra="forbid")
21
+ token: Secret | None = None
22
+ code: Secret | None = None
23
+
24
+ @model_validator(mode="after")
25
+ def _one(self) -> SessionIn:
26
+ if (self.token is None) == (self.code is None):
27
+ raise ValueError("send token or code")
28
+ return self
29
+
30
+
31
+ @router.post("/auth/session", status_code=204)
32
+ def sign_in(request: Request, body: SessionIn) -> Response:
33
+ c = ctx(request)
34
+ tid: str | None = None
35
+ if body.code is not None:
36
+ tid = c.codes.redeem(body.code.get_secret_value()) # single use (INV-9)
37
+ elif body.token is not None:
38
+ tid = c.auth.tenant_for(body.token.get_secret_value())
39
+ if tid is None:
40
+ raise HTTPException(401, "sign in")
41
+ resp = Response(status_code=204)
42
+ resp.set_cookie(COOKIE, c.sessions.open(tid), max_age=SESSION_TTL_S, httponly=True,
43
+ samesite="strict", secure=request.url.scheme == "https", path="/")
44
+ return resp
45
+
46
+
47
+ @router.delete("/auth/session", status_code=204)
48
+ def sign_out(request: Request) -> Response:
49
+ sid = request.cookies.get(COOKIE)
50
+ if sid:
51
+ ctx(request).sessions.close(sid)
52
+ resp = Response(status_code=204)
53
+ resp.delete_cookie(COOKIE, path="/")
54
+ return resp
app/api/common.py ADDED
@@ -0,0 +1,102 @@
1
+ """What every route shares: the context, sign-in, and errors that reveal nothing (INV-1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, NoReturn
7
+
8
+ from fastapi import HTTPException, Request
9
+ from fastapi.exceptions import RequestValidationError
10
+ from fastapi.responses import JSONResponse
11
+
12
+ from app.accounts import AuthProvider, Sessions, SigninCodes
13
+ from app.db import Database
14
+ from app.outputs import OutputStore
15
+ from app.readonly import ReadOnlyLedger
16
+ from app.repo import RunRow, TenantRepo
17
+ from app.settings import Settings
18
+ from app.statuses import TERMINAL
19
+ from loop import Loop
20
+
21
+ COOKIE = "pm_session"
22
+ NOT_FOUND = {"detail": "not found"}
23
+
24
+
25
+ @dataclass
26
+ class Ctx:
27
+ settings: Settings
28
+ db: Database
29
+ ledger: ReadOnlyLedger
30
+ status: Loop # status only: its factories and credential lookup refuse (readonly.py)
31
+ auth: AuthProvider
32
+ sessions: Sessions
33
+ outputs: OutputStore
34
+ link_key: bytes
35
+ codes: SigninCodes
36
+ events_poll_s: float = 1.0
37
+ heartbeat_s: float = 15.0
38
+
39
+
40
+ def ctx(request: Request) -> Ctx:
41
+ c: Ctx = request.app.state.ctx
42
+ return c
43
+
44
+
45
+ def tenant(request: Request) -> str:
46
+ c = ctx(request)
47
+ header = request.headers.get("authorization", "")
48
+ if header[:7].lower() == "bearer ":
49
+ tid = c.auth.tenant_for(header[7:].strip())
50
+ else:
51
+ tid = c.sessions.tenant_for(request.cookies.get(COOKIE, ""))
52
+ if tid is None:
53
+ raise HTTPException(401, "sign in")
54
+ return tid
55
+
56
+
57
+ def repo(request: Request, tenant_id: str) -> TenantRepo:
58
+ c = ctx(request)
59
+ return TenantRepo(c.db, tenant_id, c.settings.master_key)
60
+
61
+
62
+ def not_found() -> NoReturn:
63
+ """The same answer for a missing resource and another tenant's (INV-1)."""
64
+ raise HTTPException(404, NOT_FOUND["detail"])
65
+
66
+
67
+ def unprocessable(message: str) -> NoReturn:
68
+ raise HTTPException(422, message)
69
+
70
+
71
+ def conflict(message: str) -> NoReturn:
72
+ raise HTTPException(409, message)
73
+
74
+
75
+ async def validation_errors(request: Request, exc: Exception) -> JSONResponse:
76
+ """FastAPI's default echoes the rejected input, which for `/credentials` is the value.
77
+ Keep only where and what (Auditor, design review)."""
78
+ errors = exc.errors() if isinstance(exc, RequestValidationError) else []
79
+ return JSONResponse(status_code=422, content={"detail": clean_errors(errors)})
80
+
81
+
82
+ def clean_errors(errors: Any) -> list[dict[str, Any]]:
83
+ return [{"loc": list(e.get("loc", [])), "msg": str(e.get("msg", "")),
84
+ "type": str(e.get("type", ""))} for e in errors]
85
+
86
+
87
+ def active_runs(request: Request, r: TenantRepo) -> list[RunRow]:
88
+ """Runs queued, starting, or started and not yet delivered or failed (08 §5 step 12,
89
+ KB-032). A run whose status cannot be read counts as active."""
90
+ status = ctx(request).status
91
+ out = []
92
+ for run in r.list_runs():
93
+ if run.state == "start_failed":
94
+ continue
95
+ if run.state == "started" and run.ledger_run_id:
96
+ try:
97
+ if status.status(run.ledger_run_id).status in TERMINAL:
98
+ continue
99
+ except Exception: # noqa: S110 (unreadable: refuse rather than move its service)
100
+ pass
101
+ out.append(run)
102
+ return out