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.
- app/__init__.py +2 -0
- app/__main__.py +31 -0
- app/_web/404/index.html +1 -0
- app/_web/404.html +1 -0
- app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
- app/_web/__next._full.txt +36 -0
- app/_web/__next._tree.txt +4 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
- app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
- app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
- app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
- app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
- app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
- app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
- app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
- app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
- app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
- app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
- app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
- app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
- app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
- app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
- app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
- app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
- app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
- app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
- app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
- app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
- app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
- app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
- app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
- app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
- app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
- app/_web/_not-found/__next._full.txt +24 -0
- app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
- app/_web/_not-found/__next._tree.txt +4 -0
- app/_web/_not-found/index.html +1 -0
- app/_web/_not-found/index.txt +24 -0
- app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
- app/_web/credentials/__next._full.txt +39 -0
- app/_web/credentials/__next._tree.txt +5 -0
- app/_web/credentials/index.html +1 -0
- app/_web/credentials/index.txt +39 -0
- app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
- app/_web/executors/__next._full.txt +39 -0
- app/_web/executors/__next._tree.txt +5 -0
- app/_web/executors/index.html +1 -0
- app/_web/executors/index.txt +39 -0
- app/_web/index.html +1 -0
- app/_web/index.txt +36 -0
- app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
- app/_web/new/__next._full.txt +39 -0
- app/_web/new/__next._tree.txt +5 -0
- app/_web/new/index.html +1 -0
- app/_web/new/index.txt +39 -0
- app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
- app/_web/profiles/__next._full.txt +39 -0
- app/_web/profiles/__next._tree.txt +5 -0
- app/_web/profiles/index.html +1 -0
- app/_web/profiles/index.txt +39 -0
- app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
- app/_web/run/__next._full.txt +40 -0
- app/_web/run/__next._tree.txt +5 -0
- app/_web/run/index.html +1 -0
- app/_web/run/index.txt +40 -0
- app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
- app/_web/services/__next._full.txt +39 -0
- app/_web/services/__next._tree.txt +5 -0
- app/_web/services/index.html +1 -0
- app/_web/services/index.txt +39 -0
- app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
- app/_web/setup/__next._full.txt +39 -0
- app/_web/setup/__next._tree.txt +5 -0
- app/_web/setup/index.html +1 -0
- app/_web/setup/index.txt +39 -0
- app/_web/signin/__next._full.txt +30 -0
- app/_web/signin/__next._tree.txt +4 -0
- app/_web/signin/__next.signin.__PAGE__.txt +36 -0
- app/_web/signin/index.html +1 -0
- app/_web/signin/index.txt +30 -0
- app/accounts.py +137 -0
- app/addresses.py +30 -0
- app/api/__init__.py +61 -0
- app/api/auth_routes.py +54 -0
- app/api/common.py +102 -0
- app/api/config_routes.py +562 -0
- app/api/links.py +22 -0
- app/api/run_routes.py +311 -0
- app/api/setup_routes.py +267 -0
- app/bundled.py +521 -0
- app/compose.py +229 -0
- app/db.py +186 -0
- app/executor_config.py +95 -0
- app/keys.py +29 -0
- app/launcher.py +389 -0
- app/leases.py +40 -0
- app/literature_sources.py +131 -0
- app/model_endpoints.py +104 -0
- app/model_search.py +252 -0
- app/outputs.py +77 -0
- app/paths.py +93 -0
- app/readonly.py +70 -0
- app/repo.py +746 -0
- app/scan.py +65 -0
- app/server.py +24 -0
- app/settings.py +125 -0
- app/setup.py +117 -0
- app/ssh_chain.py +102 -0
- app/ssh_hosts.py +244 -0
- app/ssh_machines.py +101 -0
- app/statuses.py +4 -0
- app/supervisor.py +87 -0
- app/tunnels.py +222 -0
- app/unseal.py +27 -0
- app/worker.py +657 -0
- executor/__init__.py +47 -0
- executor/base.py +161 -0
- executor/common.py +139 -0
- executor/local_docker.py +239 -0
- executor/local_process.py +247 -0
- executor/ssh.py +481 -0
- executor/types.py +128 -0
- executor/watchdog.py +108 -0
- ledger/__init__.py +46 -0
- ledger/artifacts.py +62 -0
- ledger/canonical.py +48 -0
- ledger/core.py +337 -0
- ledger/errors.py +36 -0
- ledger/model.py +107 -0
- ledger/payloads.py +228 -0
- ledger/rules.py +204 -0
- ledger/storage.py +226 -0
- llm/__init__.py +45 -0
- llm/backends/__init__.py +0 -0
- llm/backends/anthropic_backend.py +145 -0
- llm/backends/base.py +87 -0
- llm/backends/openai_backend.py +211 -0
- llm/core.py +416 -0
- llm/prices.py +98 -0
- llm/tokens.py +18 -0
- llm/types.py +106 -0
- loop/__init__.py +19 -0
- loop/config.py +108 -0
- loop/core.py +1160 -0
- loop/decide.py +61 -0
- loop/harness.py +132 -0
- loop/prompts.py +88 -0
- loop/view.py +202 -0
- puremethod-0.1.0.dist-info/METADATA +79 -0
- puremethod-0.1.0.dist-info/RECORD +171 -0
- puremethod-0.1.0.dist-info/WHEEL +4 -0
- puremethod-0.1.0.dist-info/entry_points.txt +2 -0
- puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
- puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
- purescholar/__init__.py +43 -0
- purescholar/client.py +380 -0
- purescholar/types.py +164 -0
- puretrace/__init__.py +49 -0
- puretrace/client.py +320 -0
- puretrace/types.py +166 -0
- writer/__init__.py +8 -0
- writer/core.py +430 -0
- writer/facts.py +162 -0
- writer/latex.py +62 -0
- writer/lint.py +85 -0
- writer/macros.py +114 -0
- writer/render.py +120 -0
- writer/tectonic.py +79 -0
- 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
|