ctx-store 0.7.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.
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: ctx-store
3
+ Version: 0.7.0
4
+ Summary: A markdown context store for coding agents: validated writes, budgeted reads, audit trail.
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Dynamic: license-file
10
+
11
+ # ctx-store
12
+
13
+ `ctx` is a markdown context store for coding agents: one folder of `*.md` rows with YAML frontmatter as
14
+ the schema, a CLI with validated structured writes (`log`, `fm`, `new`, `move`), budgeted reads
15
+ (`brief`, `find --budget`, `resolve`, `get --section --tail`), a per-actor audit trail and a maintenance
16
+ pass (`init`, `validate`, `doctor`, `maintain`, `migrate`). The same core serves three front-ends: the CLI (Claude
17
+ Code hooks and skills call it), an Anthropic memory-tool handler (`view create str_replace insert delete
18
+ rename`) and an MCP server (stdio for Claude Desktop; HTTP with authentication for claude.ai, as a
19
+ separate package).
20
+
21
+ Production-grade by design: Python stdlib only (≥ 3.10), runs from a plain copy with no install, fixed
22
+ exit-code table, `--json` envelope (`"api": 1`), temp + rename writes under a lock, secret guard on every
23
+ payload, read-only store support, no writable state under the store for reads.
24
+
25
+ ## Interface first
26
+
27
+ The interface is the contract: verbs, fixed errors and the doc model. Storage is a backend behind it.
28
+ Markdown files are the default backend, so a store stays a folder you can read and edit; the same calls
29
+ give the same answers on any other backend (`ctxstore/backend.py`, `ctx help backends`).
30
+
31
+ ## Try it
32
+
33
+ ```sh
34
+ git clone https://github.com/MdaaaaO/ctx-store && cd ctx-store
35
+ ./ctx --version
36
+ ./ctx doctor --store tests/fixtures/store-v1
37
+ ./ctx help errors
38
+ ```
39
+
40
+ No install step: `ctx` runs from a plain copy of the repo on Python ≥ 3.10. The interface (verbs, exit
41
+ codes, error strings, `--json` envelope, environment) is [`docs/interface.md`](docs/interface.md);
42
+ `ctx help` prints from the same file. Tests: `make ci`.
43
+
44
+ ## Hooks
45
+
46
+ Every call a harness hook needs is one line. A write names its store; a read may find it by walking up.
47
+
48
+ | When | Call |
49
+ |---|---|
50
+ | Setup on a new machine, or a schema upgrade | `ctx init --store <path> --settings <file> --types <folder>` (add `--upgrade` for a new version: files edited since are kept) |
51
+ | After a change under the store | `ctx validate --changed` (add `--adopt` while writes still come from outside ctx) |
52
+ | Heartbeat | `ctx touch --session <id>` |
53
+ | Session start | `ctx brief --registry` |
54
+ | After a compaction | `ctx brief --session <id>` |
55
+ | A step landed | `ctx log <doc> "<what happened>"` |
56
+ | Session end, or on a timer | `ctx maintain` |
57
+ | CI, after a schema change | `ctx migrate --check` |
58
+
59
+ ## Claude Desktop
60
+
61
+ ```json
62
+ {"mcpServers": {"ctx": {"command": "/path/to/ctx-store/ctx", "args": ["mcp"],
63
+ "env": {"CTX_STORE": "/path/to/store", "CTX_ACTOR": "desktop"}}}}
64
+ ```
65
+
66
+ On Windows with the store and `ctx` inside WSL, Claude Desktop starts it through `wsl.exe`, and the
67
+ environment goes into the arguments:
68
+
69
+ ```json
70
+ {"mcpServers": {"ctx": {"command": "wsl.exe",
71
+ "args": ["-e", "env", "CTX_STORE=/home/you/store", "CTX_ACTOR=desktop", "CTX_NO_WALK=1",
72
+ "/home/you/ctx-store/ctx", "mcp"]}}}
73
+ ```
74
+
75
+ | Install | `claude_desktop_config.json` is in |
76
+ |---|---|
77
+ | Windows, installer | `%APPDATA%\Claude\` |
78
+ | Windows, Microsoft Store | `%LOCALAPPDATA%\Packages\Claude_*\LocalCache\Roaming\Claude\` |
79
+ | macOS | `~/Library/Application Support/Claude/` |
80
+
81
+ Tested with the Microsoft Store install on Windows with WSL2: the tools are listed, and `brief`, `find`
82
+ and `log` work. The other two rows are where Claude Desktop documents its config; they were not tested
83
+ here.
84
+
85
+ Every built verb is a tool (`ctx_brief`, `ctx_find`, `ctx_log`, …). `ctx memory` takes the input of
86
+ Anthropic's memory tool on stdin.
87
+
88
+ ## claude.ai
89
+
90
+ `ctx-serve` puts the same tools behind HTTP with authentication (OAuth for claude.ai, a bearer token for
91
+ Claude Code), for a store on your machine behind a tunnel: [`docs/connector.md`](docs/connector.md). It is
92
+ a separate package; the core has no network.
93
+
94
+ ## Speed
95
+
96
+ Milliseconds per call as a caller sees it: a fresh process each time, interpreter start included
97
+ (about 40 ms of every number). Generated Markdown stores of 3.1 MB and 39.2 MB, 20 runs per call,
98
+ Python 3.14 on Linux under WSL2. The two stores were measured in separate runs on a machine that was
99
+ not idle: compare a row with the first row of its own column. `python3 bench/bench.py` reproduces it.
100
+
101
+ | Call | 225 docs p50 | p95 | 3 000 docs p50 | p95 |
102
+ |---|---:|---:|---:|---:|
103
+ | start of the interpreter (`--version`) | 35 | 38 | 34 | 37 |
104
+ | `brief <doc>` | 35 | 40 | 34 | 39 |
105
+ | `get <doc> --section --tail 5` | 35 | 37 | 35 | 36 |
106
+ | `view <doc>` | 34 | 35 | 35 | 39 |
107
+ | `brief --registry` | 42 | 44 | 127 | 140 |
108
+ | `brief --session <id>` | 42 | 50 | 127 | 134 |
109
+ | `resolve <key>` | 41 | 46 | 103 | 107 |
110
+ | `find <word in one doc>` | 44 | 45 | 155 | 163 |
111
+ | `find <common word>` | 69 | 77 | 173 | 199 |
112
+ | `find --tag` | 41 | 59 | 123 | 144 |
113
+ | `validate` | 54 | 60 | 295 | 309 |
114
+ | `validate --changed` | 49 | 55 | 184 | 194 |
115
+
116
+ **No search cache.** The rule was: a cache enters only above 3 000 docs, with `find` over 200 ms at
117
+ p95, or when a lookup needs ranked search a scan cannot give. At 3 000 docs every `find` stays under
118
+ 200 ms. `find` matches terms and ranks its hits as part of the same scan (`ctx help find`), which
119
+ finds 17 of the 18 lookups in `bench/eval/` where the phrase match before it found 6; the one it
120
+ misses is a paraphrase, which an index of words would miss too. A task described in a sentence
121
+ or two finds its doc as well (7 of 7 in the eval). That is where the scan costs most: about 0.1 s for
122
+ 25 words on 225 docs, about 0.9 s on 3 000. So the store has no index to build,
123
+ validate or lose. The question returns when a store passes 3 000 docs, or when lookups need
124
+ synonyms.
125
+
126
+ `validate --changed`, the call behind the after-write hook, stays under its 300 ms budget at 3 000 docs.
127
+
128
+ **Status:** P4: every verb except `row` is built; measured; no cache. Design and phasing:
129
+ [#1](https://github.com/MdaaaaO/ctx-store/issues/1). Licence: MIT.
@@ -0,0 +1,36 @@
1
+ ctx_store-0.7.0.dist-info/licenses/LICENSE,sha256=QfqxBdBwsjJrZNNocQsz9Ni7_c2Xq3IDw7pFXq3O3Yc,1064
2
+ ctxserve/__init__.py,sha256=gqIdwu3jlNJJRQ_D3_VaHryUROL7URPFWVnF_YC-bt8,178
3
+ ctxserve/__main__.py,sha256=WAhr-TR-SUgzU8lRacTT70n91sBZgxPy6yff-Zyeu3E,55
4
+ ctxserve/auth.py,sha256=Vm4sTbNMs-u0bzwuvvSNjVua-2CPUScfJfV0waoDESQ,10698
5
+ ctxserve/server.py,sha256=FeoGtphbL1uOJAsRoi-ct8uASwRAe7zPuaqP-0H55AQ,14585
6
+ ctxstore/VERSION,sha256=ln2a-xATRmZxZvLnboGRC8GQSI19QdUMoAcunZLwDjI,6
7
+ ctxstore/__init__.py,sha256=MkyaMWFRN3wu6eN8j_lo-W_s13YlqY7pJaf78_5cBhI,155
8
+ ctxstore/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
9
+ ctxstore/backend.py,sha256=tqh5gnSE8cCaAYiPWkBVKGwbsZSWr0EJjFwXdYt8qN8,9145
10
+ ctxstore/bootstrap.py,sha256=-leR5k9qSRupiIAZrMjuBG0SaPoJG0gq8xnDP9kem2E,6699
11
+ ctxstore/cli.py,sha256=BjGdF5QhhnYEnFW3004n_bJkMiBAJAtaSzl1faZB84k,11166
12
+ ctxstore/clock.py,sha256=iB0rHTYv-Jk_ieo_QUq7tWI3fF5GsAxojF_iU0sgEHw,191
13
+ ctxstore/config.py,sha256=zwJF-xLXYKWI_7plkX-Xp2GqS-hpLk6leR5c92r-XAg,1894
14
+ ctxstore/contract.py,sha256=jVyUSgTZtfRH5dPESggIp6R9E6dJWvtHwceCd3efKU0,3326
15
+ ctxstore/doctor.py,sha256=PuAIwXtApvMgncdyBy0Lo21zsv4-rkf2MfDgSRyy774,1710
16
+ ctxstore/frontmatter.py,sha256=6UoSXi4jaZURMsIzk-X0Jj62yHDiJKHR0rQ-yC4QeFs,3676
17
+ ctxstore/fs.py,sha256=nc_wKHsryHCR0C5cyRpeleWJ05XX3Ssvw2OdtbWh6iU,16014
18
+ ctxstore/interface.md,sha256=2LSAM-K96hQkrBiVh7pj2fg7BVAylMHkJMverWrpL5w,31827
19
+ ctxstore/links.py,sha256=NPJD9pwIqiwNX_zsLfP2mx2dJQ_XEHRC3t7w7jp5xPE,2129
20
+ ctxstore/markdown.py,sha256=O4b4cVqZXYbftShGkcmlpBB5wL73dj8HWCgadl_jCoc,2594
21
+ ctxstore/mcp.py,sha256=eLjkzZx_tRJwj7XhklwnYhczE5IjL38cCaGeqP6CmbE,12128
22
+ ctxstore/memory_tool.py,sha256=Oz9F_MC8azRy5jiVwP-Fwl96SuPmP28XxPiXg9i2SGg,2574
23
+ ctxstore/reads.py,sha256=BL4IySa1ecOf2fvb8raYiYsm07KbqDSYiYJhiHa-JtI,12951
24
+ ctxstore/search.py,sha256=6hXexfaGiUmP34lYzhDV2zW_0SbSccH-_plxqMPbJGA,9102
25
+ ctxstore/secrets.py,sha256=-GgFAJSQIFGIqh20USYvzt1NweCvmC0BJeNrJi4kOIc,763
26
+ ctxstore/sections.py,sha256=Cn0kKq9osqLNh1K5HS6xhrBdbJVDIjYOyfhgAySchII,7435
27
+ ctxstore/spec.py,sha256=SV-0p83RmQbX2acla0ne-HeNL6PsDdXp19k23RIP3Yw,1395
28
+ ctxstore/store.py,sha256=vHLV5X2ZZGMVRAMufRdRTY9fPYA4YeDhpd7choPNUrA,16447
29
+ ctxstore/upkeep.py,sha256=_El8OVAwAOAWVI3VtYxYJYhRXyC_N9h5A3DVLC6dkBk,12567
30
+ ctxstore/verbs.py,sha256=rMr0LLOMMeyJ0inOsA2ItycUuEPF2TZHQfj-_CHXxIA,13475
31
+ ctxstore/writes.py,sha256=LnSnra6qJOGR8VFFe3OwUQ77EZAFqMBOSwnP-ZwQfoQ,7729
32
+ ctx_store-0.7.0.dist-info/METADATA,sha256=bNN1Q5ibyM2_2edmQR-Qc_NgmfkO0yhNOYfWhcx4zIU,6160
33
+ ctx_store-0.7.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
34
+ ctx_store-0.7.0.dist-info/entry_points.txt,sha256=AfhG-KGmy0tkoTczBWT3oFnTTfwn9ZMVwJ_nsZlrbPA,75
35
+ ctx_store-0.7.0.dist-info/top_level.txt,sha256=qg8AYNV6uf7hBN0L9iL-XhgLwN1qxcdvtpvR0-afE_4,18
36
+ ctx_store-0.7.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ ctx = ctxstore.cli:main
3
+ ctx-serve = ctxserve.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MdaaaaO
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ ctxserve
2
+ ctxstore
ctxserve/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """ctx over HTTP: the MCP server of `ctx mcp` behind Streamable HTTP, with
2
+ authentication. Not part of the core: the core has no network and no server.
3
+ Standard library only."""
ctxserve/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .server import main
4
+
5
+ sys.exit(main())
ctxserve/auth.py ADDED
@@ -0,0 +1,235 @@
1
+ """Who may call: a static bearer token, or OAuth 2.1 for one owner.
2
+
3
+ The OAuth side is an authorization server small enough to read: dynamic
4
+ client registration, the authorization code grant with PKCE (S256), refresh
5
+ tokens that rotate. There is one resource owner. Consent is the owner's
6
+ passphrase, typed into the page this server shows.
7
+
8
+ Tokens, codes and the passphrase are never stored: the state file holds
9
+ sha256 digests, and lives outside the store.
10
+ """
11
+ import base64
12
+ import hashlib
13
+ import hmac
14
+ import json
15
+ import os
16
+ import re
17
+ import secrets
18
+ import threading
19
+ import time
20
+ import urllib.parse
21
+
22
+ CODE_SECONDS = 300
23
+ ACCESS_SECONDS = 3600
24
+ REFRESH_SECONDS = 30 * 86400
25
+ FORM_SECONDS = 600
26
+ ATTEMPTS, WINDOW = 5, 600 # wrong passphrases allowed per ten minutes
27
+ CLIENTS = 200 # registered clients kept; the oldest go first
28
+ CALLBACKS = (
29
+ re.compile(r"^https://claude\.ai/api/mcp/auth_callback$"),
30
+ re.compile(r"^https://claude\.com/api/mcp/auth_callback$"),
31
+ re.compile(r"^http://(localhost|127\.0\.0\.1)(:\d{1,5})?/callback$"),
32
+ )
33
+
34
+
35
+ def digest(value):
36
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
37
+
38
+
39
+ def same(left, right):
40
+ return hmac.compare_digest(left.encode("utf-8"), right.encode("utf-8"))
41
+
42
+
43
+ def allowed_callback(uri):
44
+ return isinstance(uri, str) and any(rule.match(uri) for rule in CALLBACKS)
45
+
46
+
47
+ def challenge_of(verifier):
48
+ raw = hashlib.sha256(verifier.encode("ascii")).digest()
49
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
50
+
51
+
52
+ class Refused(Exception):
53
+ """An OAuth error: (HTTP status, error code, description)."""
54
+
55
+ def __init__(self, status, code, text=""):
56
+ super().__init__(code)
57
+ self.status, self.code, self.text = status, code, text
58
+
59
+
60
+ class State:
61
+ """The server's memory between requests, kept in one JSON file."""
62
+
63
+ def __init__(self, path, clock=time.time):
64
+ self.path, self.clock = path, clock
65
+ self.data = {"clients": {}, "codes": {}, "access": {}, "refresh": {}, "forms": {}, "failures": []}
66
+ if path and os.path.exists(path):
67
+ with open(path, encoding="utf-8") as handle:
68
+ self.data.update(json.load(handle))
69
+
70
+ def save(self):
71
+ now = self.clock()
72
+ for name in ("codes", "access", "refresh", "forms"):
73
+ self.data[name] = {k: v for k, v in self.data[name].items() if v["until"] > now}
74
+ self.data["failures"] = [at for at in self.data["failures"] if at > now - WINDOW]
75
+ clients = sorted(self.data["clients"].items(), key=lambda item: item[1]["at"])
76
+ self.data["clients"] = dict(clients[-CLIENTS:])
77
+ if not self.path:
78
+ return
79
+ os.makedirs(os.path.dirname(self.path), mode=0o700, exist_ok=True)
80
+ temp = f"{self.path}.{os.getpid()}.tmp"
81
+ handle = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
82
+ with os.fdopen(handle, "w", encoding="utf-8") as out:
83
+ json.dump(self.data, out)
84
+ os.replace(temp, self.path)
85
+
86
+
87
+ class Auth:
88
+ def __init__(self, url, secret=None, token=None, state=None, clock=time.time):
89
+ self.url = url.rstrip("/")
90
+ self.secret, self.token = secret, token
91
+ self.clock = clock
92
+ self.state = state or State(None, clock)
93
+ self.lock = threading.RLock() # requests run in threads; the state is one
94
+
95
+ # --- the resource: who may call /mcp ---
96
+
97
+ def allows(self, header):
98
+ """Whether an Authorization header carries a token this server gave
99
+ out, or the static one."""
100
+ with self.lock:
101
+ kind, _, value = (header or "").partition(" ")
102
+ if kind.lower() != "bearer" or not value.strip():
103
+ return False
104
+ value = value.strip()
105
+ if self.token and same(value, self.token):
106
+ return True
107
+ entry = self.state.data["access"].get(digest(value))
108
+ return bool(entry) and entry["until"] > self.clock()
109
+
110
+ def challenge(self):
111
+ return f'Bearer resource_metadata="{self.url}/.well-known/oauth-protected-resource"'
112
+
113
+ # --- discovery ---
114
+
115
+ def resource_metadata(self):
116
+ return {"resource": f"{self.url}/mcp", "authorization_servers": [self.url],
117
+ "bearer_methods_supported": ["header"]}
118
+
119
+ def server_metadata(self):
120
+ return {
121
+ "issuer": self.url,
122
+ "authorization_endpoint": f"{self.url}/authorize",
123
+ "token_endpoint": f"{self.url}/token",
124
+ "registration_endpoint": f"{self.url}/register",
125
+ "response_types_supported": ["code"],
126
+ "grant_types_supported": ["authorization_code", "refresh_token"],
127
+ "code_challenge_methods_supported": ["S256"],
128
+ "token_endpoint_auth_methods_supported": ["none"],
129
+ }
130
+
131
+ # --- registration ---
132
+
133
+ def register(self, body):
134
+ with self.lock:
135
+ uris = body.get("redirect_uris") if isinstance(body, dict) else None
136
+ if not isinstance(uris, list) or not uris or not all(allowed_callback(uri) for uri in uris):
137
+ raise Refused(400, "invalid_redirect_uri", "redirect_uris must be Claude's callback or a loopback callback")
138
+ name = body.get("client_name")
139
+ client = secrets.token_urlsafe(24)
140
+ self.state.data["clients"][client] = {
141
+ "redirect_uris": uris, "name": name[:80] if isinstance(name, str) else "", "at": self.clock()}
142
+ self.state.save()
143
+ return {"client_id": client, "redirect_uris": uris, "token_endpoint_auth_method": "none",
144
+ "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"],
145
+ "client_name": self.state.data["clients"][client]["name"]}
146
+
147
+ # --- authorization ---
148
+
149
+ def request(self, query):
150
+ """The checked parameters of an authorization request. A request whose
151
+ client or redirect cannot be trusted is refused here and never
152
+ redirected."""
153
+ with self.lock:
154
+ client = self.state.data["clients"].get(query.get("client_id", ""))
155
+ if not client:
156
+ raise Refused(400, "invalid_client", "unknown client_id")
157
+ redirect = query.get("redirect_uri", "")
158
+ if redirect not in client["redirect_uris"] or not allowed_callback(redirect):
159
+ raise Refused(400, "invalid_request", "redirect_uri is not the client's")
160
+ if query.get("response_type") != "code":
161
+ raise Refused(400, "unsupported_response_type")
162
+ challenge = query.get("code_challenge", "")
163
+ if query.get("code_challenge_method") != "S256" or not re.fullmatch(r"[A-Za-z0-9_-]{43}", challenge):
164
+ raise Refused(400, "invalid_request", "PKCE with S256 is required")
165
+ return {"client_id": query["client_id"], "redirect_uri": redirect, "code_challenge": challenge,
166
+ "state": query.get("state", ""), "name": client["name"]}
167
+
168
+ def form(self, request):
169
+ """A one-time token that ties the consent page to the request it shows."""
170
+ with self.lock:
171
+ token = secrets.token_urlsafe(24)
172
+ self.state.data["forms"][digest(token)] = {"request": request, "until": self.clock() + FORM_SECONDS}
173
+ self.state.save()
174
+ return token
175
+
176
+ def consent(self, form, passphrase):
177
+ """The redirect that carries the code, once the owner's passphrase is
178
+ right."""
179
+ with self.lock:
180
+ if not self.secret:
181
+ raise Refused(503, "server_error", "no owner passphrase is set")
182
+ now = self.clock()
183
+ if len([at for at in self.state.data["failures"] if at > now - WINDOW]) >= ATTEMPTS:
184
+ raise Refused(429, "access_denied", "too many wrong passphrases; try again later")
185
+ entry = self.state.data["forms"].pop(digest(form or ""), None)
186
+ if not entry or entry["until"] <= now:
187
+ self.state.save()
188
+ raise Refused(400, "invalid_request", "the page has expired; start again from Claude")
189
+ request = entry["request"]
190
+ if not same(passphrase or "", self.secret):
191
+ self.state.data["failures"].append(now)
192
+ self.state.save()
193
+ raise Refused(403, "access_denied", "wrong passphrase")
194
+ code = secrets.token_urlsafe(32)
195
+ self.state.data["codes"][digest(code)] = {**request, "until": now + CODE_SECONDS}
196
+ self.state.save()
197
+ answer = {"code": code}
198
+ if request["state"]:
199
+ answer["state"] = request["state"]
200
+ joiner = "&" if "?" in request["redirect_uri"] else "?"
201
+ return request["redirect_uri"] + joiner + urllib.parse.urlencode(answer)
202
+
203
+ # --- tokens ---
204
+
205
+ def _issue(self, client):
206
+ now = self.clock()
207
+ access, refresh = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
208
+ self.state.data["access"][digest(access)] = {"client_id": client, "until": now + ACCESS_SECONDS}
209
+ self.state.data["refresh"][digest(refresh)] = {"client_id": client, "until": now + REFRESH_SECONDS}
210
+ self.state.save()
211
+ return {"access_token": access, "token_type": "Bearer", "expires_in": ACCESS_SECONDS,
212
+ "refresh_token": refresh}
213
+
214
+ def exchange(self, form):
215
+ with self.lock:
216
+ grant = form.get("grant_type")
217
+ now = self.clock()
218
+ if grant == "authorization_code":
219
+ entry = self.state.data["codes"].pop(digest(form.get("code", "")), None)
220
+ self.state.save() # a code is spent by its first use, right or wrong
221
+ verifier = form.get("code_verifier", "")
222
+ if (not entry or entry["until"] <= now
223
+ or form.get("client_id") != entry["client_id"]
224
+ or form.get("redirect_uri") != entry["redirect_uri"]
225
+ or not re.fullmatch(r"[A-Za-z0-9._~-]{43,128}", verifier)
226
+ or not same(challenge_of(verifier), entry["code_challenge"])):
227
+ raise Refused(400, "invalid_grant")
228
+ return self._issue(entry["client_id"])
229
+ if grant == "refresh_token":
230
+ entry = self.state.data["refresh"].pop(digest(form.get("refresh_token", "")), None)
231
+ self.state.save() # rotation: the old refresh token is gone either way
232
+ if not entry or entry["until"] <= now or form.get("client_id") != entry["client_id"]:
233
+ raise Refused(400, "invalid_grant")
234
+ return self._issue(entry["client_id"])
235
+ raise Refused(400, "unsupported_grant_type")