celik-muhammed commited on
Commit
ea2362b
·
verified ·
1 Parent(s): b820569

Delete _page_feedback

Browse files
_page_feedback/__init__.py DELETED
@@ -1,47 +0,0 @@
1
- # scikitplot/_externals/_sphinx_ext/_sphinx_feedback/__init__.py
2
- #
3
- # Authors: The scikit-plots developers
4
- # SPDX-License-Identifier: BSD-3-Clause
5
-
6
- """
7
- Privacy-minimal page feedback for Sphinx and static HTML.
8
-
9
- The dependency-free helpers in this package can be imported without Sphinx.
10
- Sphinx-specific integration is imported lazily by :func:`setup`.
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- from ._contracts import (
16
- FeedbackConflictError,
17
- FeedbackValidationError,
18
- build_feedback_event,
19
- canonical_feedback_bytes,
20
- decode_feedback_request,
21
- feedback_event_request_hash,
22
- feedback_request_from_event,
23
- feedback_request_hash,
24
- parse_feedback_request,
25
- )
26
-
27
- __version__ = "0.6.1"
28
-
29
- __all__ = [
30
- "FeedbackConflictError",
31
- "FeedbackValidationError",
32
- "build_feedback_event",
33
- "canonical_feedback_bytes",
34
- "decode_feedback_request",
35
- "feedback_event_request_hash",
36
- "feedback_request_from_event",
37
- "feedback_request_hash",
38
- "parse_feedback_request",
39
- "setup",
40
- ]
41
-
42
-
43
- def setup(app):
44
- """Register the Sphinx adapter lazily so helpers remain dependency-free."""
45
- from ._sphinx import setup_extension # noqa: PLC0415 -- Sphinx is optional
46
-
47
- return setup_extension(app)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
_page_feedback/_contracts.py DELETED
@@ -1,560 +0,0 @@
1
- """Strict, privacy-minimal contracts for generic page feedback."""
2
-
3
- from __future__ import annotations
4
-
5
- import hashlib
6
- import json
7
- import re
8
- import unicodedata
9
- from typing import Any
10
-
11
- REQUEST_CONTRACT = "page.feedback-request.v1"
12
- EVENT_CONTRACT = "page.feedback-event.v1"
13
- RECEIPT_CONTRACT = "page.feedback-receipt.v1"
14
- AGGREGATE_CONTRACT = "page.feedback-aggregate.v3"
15
-
16
- # A 2,000-code-point Unicode comment can exceed 8 KiB once encoded. Keep the
17
- # canonical envelope and transport limits aligned so the documented comment
18
- # limit remains usable for non-ASCII text as well as ASCII prose.
19
- MAX_REQUEST_BYTES = 16 * 1024
20
- MAX_AGGREGATE_BYTES = 2 * 1024 * 1024
21
- MAX_SITE_ID = 96
22
- MAX_PAGE_ID = 512
23
- MAX_PAGE_REVISION = 128
24
- MAX_COMMENT = 2000
25
- MAX_CONTRIBUTOR = 80
26
-
27
- _SITE_ID_RE = re.compile(r"[A-Za-z0-9](?:[A-Za-z0-9._-]{0,94}[A-Za-z0-9])?\Z")
28
- _FEEDBACK_ID_RE = re.compile(r"feedback-[0-9a-f]{48}\Z")
29
- _REQUEST_HASH_RE = re.compile(r"[0-9a-f]{64}\Z")
30
- _ALLOWED_TOP = frozenset(
31
- {
32
- "contract",
33
- "action",
34
- "site_id",
35
- "page_id",
36
- "page_revision",
37
- "feedback_id",
38
- "rating",
39
- "mode",
40
- "comment",
41
- "contributor",
42
- }
43
- )
44
- _ALLOWED_CONTRIBUTOR = frozenset({"display_name"})
45
- _ALLOWED_EVENT_TOP = frozenset(
46
- {
47
- "contract",
48
- "site_id",
49
- "page_id",
50
- "page_revision",
51
- "feedback",
52
- },
53
- )
54
- _ALLOWED_EVENT_FEEDBACK = frozenset(
55
- {
56
- "id",
57
- "rating",
58
- "mode",
59
- "comment",
60
- "contributor",
61
- },
62
- )
63
-
64
-
65
- class FeedbackValidationError(ValueError):
66
- """Raised when a public feedback envelope violates the contract."""
67
-
68
- def __init__(self, message: str, *, code: str = "invalid_feedback") -> None:
69
- super().__init__(message)
70
- self.code = code
71
-
72
-
73
- class FeedbackConflictError(RuntimeError):
74
- """Raised when an idempotency key is replayed with different content."""
75
-
76
-
77
- def _reject_unknown_keys(
78
- value: dict[str, Any],
79
- allowed: frozenset[str],
80
- path: str,
81
- ) -> None:
82
- extras = sorted(str(key) for key in value if key not in allowed)
83
- if extras:
84
- raise FeedbackValidationError(
85
- f"{path}: unsupported field(s): {', '.join(extras)}",
86
- code="unexpected_field",
87
- )
88
-
89
-
90
- def _nfc_text(value: Any, *, path: str, limit: int, allow_empty: bool) -> str:
91
- if not isinstance(value, str):
92
- raise FeedbackValidationError(f"{path}: must be a string")
93
- try:
94
- text = unicodedata.normalize("NFC", value)
95
- text.encode("utf-8", "strict")
96
- except (UnicodeError, ValueError) as exc:
97
- raise FeedbackValidationError(f"{path}: invalid Unicode text") from exc
98
- if len(text) > limit:
99
- raise FeedbackValidationError(f"{path}: exceeds {limit} characters")
100
- if not allow_empty and not text:
101
- raise FeedbackValidationError(f"{path}: must not be empty")
102
- return text
103
-
104
-
105
- def _public_credit(value: Any) -> str:
106
- text = _nfc_text(
107
- value,
108
- path="contributor.display_name",
109
- limit=MAX_CONTRIBUTOR,
110
- allow_empty=True,
111
- )
112
- if any(
113
- ord(ch) < 32 or ord(ch) == 127 # ruff: ignore[magic-value-comparison]
114
- for ch in text
115
- ):
116
- raise FeedbackValidationError(
117
- "contributor.display_name: control characters are not allowed"
118
- )
119
- text = re.sub(r" +", " ", text.strip(" "))
120
- if len(text) > MAX_CONTRIBUTOR:
121
- raise FeedbackValidationError(
122
- f"contributor.display_name: exceeds {MAX_CONTRIBUTOR} characters"
123
- )
124
- return text
125
-
126
-
127
- def _comment(value: Any) -> str:
128
- text = _nfc_text(
129
- value,
130
- path="comment",
131
- limit=MAX_COMMENT,
132
- allow_empty=True,
133
- ).strip(" \t\n\r")
134
- # Preserve line breaks and tabs in deliberate prose, but reject the rest of
135
- # C0/DEL. This mirrors the AI Learn reviewed-comment boundary.
136
- for ch in text:
137
- code = ord(ch)
138
- if code == 127 or ( # ruff: ignore[magic-value-comparison]
139
- code < 32 and ch not in "\t\n\r" # ruff: ignore[magic-value-comparison]
140
- ):
141
- raise FeedbackValidationError("comment: unsupported control characters")
142
- return text
143
-
144
-
145
- def normalize_page_id(value: Any) -> str:
146
- """Validate a canonical page identity without interpreting it as a URL/path."""
147
- text = _nfc_text(
148
- value, path="page_id", limit=MAX_PAGE_ID, allow_empty=False
149
- ).strip()
150
- if (
151
- text.startswith("/")
152
- or text.endswith("/")
153
- or "\\" in text
154
- or "?" in text
155
- or "#" in text
156
- or any(
157
- ord(ch) < 32 or ord(ch) == 127 # ruff: ignore[magic-value-comparison]
158
- for ch in text
159
- )
160
- ):
161
- raise FeedbackValidationError("page_id: invalid canonical page name")
162
- parts = text.split("/")
163
- if any(part in {"", ".", ".."} for part in parts):
164
- raise FeedbackValidationError("page_id: invalid path segment")
165
- return text
166
-
167
-
168
- def normalize_page_revision(value: Any) -> str:
169
- if value is None or value == "":
170
- return ""
171
- text = _nfc_text(
172
- value,
173
- path="page_revision",
174
- limit=MAX_PAGE_REVISION,
175
- allow_empty=True,
176
- ).strip()
177
- if any(
178
- ord(ch) < 32 or ord(ch) == 127 # ruff: ignore[magic-value-comparison]
179
- for ch in text
180
- ):
181
- raise FeedbackValidationError(
182
- "page_revision: control characters are not allowed",
183
- )
184
- return text
185
-
186
-
187
- def _site_id(value: Any) -> str:
188
- site_id = _nfc_text(
189
- value, path="site_id", limit=MAX_SITE_ID, allow_empty=False
190
- ).strip()
191
- if not _SITE_ID_RE.fullmatch(site_id):
192
- raise FeedbackValidationError("site_id: use a stable ASCII identifier")
193
- return site_id
194
-
195
-
196
- def normalize_site_id(value: Any) -> str:
197
- """Validate and canonicalize a stable public site identifier."""
198
- return _site_id(value)
199
-
200
-
201
- def _feedback_id(value: Any) -> str:
202
- if not isinstance(value, str) or not _FEEDBACK_ID_RE.fullmatch(value):
203
- raise FeedbackValidationError(
204
- "feedback_id: expected feedback- followed by 48 lowercase hexadecimal characters"
205
- )
206
- return value
207
-
208
-
209
- def _rating(value: Any) -> int:
210
- _value = -5 <= value <= 5 # ruff: ignore[magic-value-comparison]
211
- if isinstance(value, bool) or not isinstance(value, int) or not _value:
212
- raise FeedbackValidationError("rating: expected an integer from -5 through +5")
213
- return value
214
-
215
-
216
- def _mode(value: Any) -> str:
217
- if value not in {"quick", "detailed"}:
218
- raise FeedbackValidationError(
219
- "mode: expected 'quick' or 'detailed'",
220
- )
221
- return value
222
-
223
-
224
- def _reject_duplicate_json_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
225
- result: dict[str, Any] = {}
226
- for key, value in pairs:
227
- if key in result:
228
- raise FeedbackValidationError(f"duplicate JSON field: {key}")
229
- result[key] = value
230
- return result
231
-
232
-
233
- def _reject_nonfinite_json(value: str) -> None:
234
- raise FeedbackValidationError(f"non-finite JSON number is not allowed: {value}")
235
-
236
-
237
- def decode_feedback_request(raw: bytes | str) -> dict[str, Any]:
238
- """
239
- Decode one wire request with strict JSON semantics, then validate it.
240
-
241
- Duplicate object keys and non-finite numeric spellings are rejected before
242
- contract normalization so different JSON parsers cannot disagree about the
243
- event that an idempotency key represents.
244
- """
245
- if not isinstance(raw, (bytes, str)):
246
- raise FeedbackValidationError("Feedback body must be JSON text")
247
- try:
248
- payload = json.loads(
249
- raw,
250
- object_pairs_hook=_reject_duplicate_json_keys,
251
- parse_constant=_reject_nonfinite_json,
252
- )
253
- except FeedbackValidationError:
254
- raise
255
- except (
256
- UnicodeDecodeError,
257
- json.JSONDecodeError,
258
- RecursionError,
259
- ValueError,
260
- ) as exc:
261
- raise FeedbackValidationError(
262
- "Invalid JSON",
263
- code="invalid_json",
264
- ) from exc
265
- return parse_feedback_request(payload)
266
-
267
-
268
- def decode_feedback_event(raw: bytes | str) -> dict[str, Any]:
269
- """Decode one durable event with the same strict JSON semantics as requests."""
270
- if not isinstance(raw, (bytes, str)):
271
- raise FeedbackValidationError("Feedback event must be JSON text")
272
- try:
273
- payload = json.loads(
274
- raw,
275
- object_pairs_hook=_reject_duplicate_json_keys,
276
- parse_constant=_reject_nonfinite_json,
277
- )
278
- except FeedbackValidationError:
279
- raise
280
- except (
281
- UnicodeDecodeError,
282
- json.JSONDecodeError,
283
- RecursionError,
284
- ValueError,
285
- ) as exc:
286
- raise FeedbackValidationError(
287
- "Invalid feedback event JSON",
288
- code="invalid_event_json",
289
- ) from exc
290
- return parse_feedback_event(payload)
291
-
292
-
293
- def parse_feedback_request(payload: Any) -> dict[str, Any]:
294
- """
295
- Validate and normalize one browser feedback request.
296
-
297
- Unknown fields are rejected deliberately. This prevents future browser,
298
- device, account, referrer, IP, or analytics fields from silently becoming
299
- durable feedback data.
300
- """
301
- if not isinstance(payload, dict):
302
- raise FeedbackValidationError("Feedback body must be a JSON object")
303
- _reject_unknown_keys(payload, _ALLOWED_TOP, "feedback")
304
-
305
- if payload.get("contract") != REQUEST_CONTRACT:
306
- raise FeedbackValidationError(
307
- "Unsupported feedback contract", code="unsupported_contract"
308
- )
309
- if payload.get("action") != "submit":
310
- raise FeedbackValidationError("Unsupported feedback action")
311
-
312
- site_id = normalize_site_id(payload.get("site_id"))
313
- page_id = normalize_page_id(payload.get("page_id"))
314
- page_revision = normalize_page_revision(payload.get("page_revision", ""))
315
- feedback_id = _feedback_id(payload.get("feedback_id"))
316
- rating = _rating(payload.get("rating"))
317
- mode = _mode(payload.get("mode"))
318
-
319
- raw_contributor = payload.get("contributor", {})
320
- if raw_contributor is None:
321
- raw_contributor = {}
322
- if not isinstance(raw_contributor, dict):
323
- raise FeedbackValidationError("contributor: must be an object")
324
- _reject_unknown_keys(raw_contributor, _ALLOWED_CONTRIBUTOR, "contributor")
325
- contributor = _public_credit(raw_contributor.get("display_name", ""))
326
- if contributor.lower() == "anonymous":
327
- raise FeedbackValidationError(
328
- "contributor.display_name: 'Anonymous' is reserved; leave public credit blank instead"
329
- )
330
- comment = _comment(payload.get("comment", ""))
331
-
332
- if mode == "quick":
333
- if rating not in {-1, 1}:
334
- raise FeedbackValidationError("quick feedback rating must be -1 or +1")
335
- if comment or contributor:
336
- raise FeedbackValidationError(
337
- "quick feedback must not include comment or contributor content"
338
- )
339
-
340
- normalized: dict[str, Any] = {
341
- "contract": REQUEST_CONTRACT,
342
- "action": "submit",
343
- "site_id": site_id,
344
- "page_id": page_id,
345
- "feedback_id": feedback_id,
346
- "rating": rating,
347
- "mode": mode,
348
- "contributor": {"display_name": contributor},
349
- }
350
- if page_revision:
351
- normalized["page_revision"] = page_revision
352
- if comment:
353
- normalized["comment"] = comment
354
- return normalized
355
-
356
-
357
- def canonical_feedback_bytes(request: dict[str, Any]) -> bytes:
358
- """Return canonical UTF-8 JSON used for request commitments/idempotency."""
359
- normalized = parse_feedback_request(request)
360
- encoded = json.dumps(
361
- normalized,
362
- ensure_ascii=False,
363
- sort_keys=True,
364
- separators=(",", ":"),
365
- ).encode("utf-8")
366
- if len(encoded) > MAX_REQUEST_BYTES:
367
- raise FeedbackValidationError(
368
- f"feedback request exceeds {MAX_REQUEST_BYTES} canonical bytes"
369
- )
370
- return encoded
371
-
372
-
373
- def feedback_request_hash(request: dict[str, Any]) -> str:
374
- """Return a deterministic commitment to the normalized feedback request."""
375
- return hashlib.sha256(canonical_feedback_bytes(request)).hexdigest()
376
-
377
-
378
- def build_feedback_event(request: dict[str, Any]) -> dict[str, Any]:
379
- """Project a request into the minimal durable feedback event."""
380
- normalized = parse_feedback_request(request)
381
- feedback: dict[str, Any] = {
382
- "id": normalized["feedback_id"],
383
- "rating": normalized["rating"],
384
- "mode": normalized["mode"],
385
- "contributor": normalized["contributor"]["display_name"] or "Anonymous",
386
- }
387
- if normalized.get("comment"):
388
- feedback["comment"] = normalized["comment"]
389
- event: dict[str, Any] = {
390
- "contract": EVENT_CONTRACT,
391
- "site_id": normalized["site_id"],
392
- "page_id": normalized["page_id"],
393
- "feedback": feedback,
394
- }
395
- if normalized.get("page_revision"):
396
- event["page_revision"] = normalized["page_revision"]
397
- return event
398
-
399
-
400
- def feedback_request_from_event(event: dict[str, Any]) -> dict[str, Any]:
401
- """
402
- Reconstruct the unique normalized request represented by a durable event.
403
-
404
- This inverse projection is intentionally strict. It gives storage/provider
405
- adapters an independent way to verify that a supplied request commitment
406
- actually commits to the exact durable event they are about to accept.
407
- """
408
- normalized = parse_feedback_event(event)
409
- feedback = normalized["feedback"]
410
- contributor = feedback["contributor"]
411
- request: dict[str, Any] = {
412
- "contract": REQUEST_CONTRACT,
413
- "action": "submit",
414
- "site_id": normalized["site_id"],
415
- "page_id": normalized["page_id"],
416
- "feedback_id": feedback["id"],
417
- "rating": feedback["rating"],
418
- "mode": feedback["mode"],
419
- "contributor": {
420
- "display_name": "" if contributor == "Anonymous" else contributor
421
- },
422
- }
423
- if normalized.get("page_revision"):
424
- request["page_revision"] = normalized["page_revision"]
425
- if feedback.get("comment"):
426
- request["comment"] = feedback["comment"]
427
- return parse_feedback_request(request)
428
-
429
-
430
- def feedback_event_request_hash(event: dict[str, Any]) -> str:
431
- """Return the request commitment implied by one canonical durable event."""
432
- return feedback_request_hash(feedback_request_from_event(event))
433
-
434
-
435
- def parse_feedback_event( # ruff: ignore[too-many-branches]
436
- payload: Any,
437
- ) -> dict[str, Any]:
438
- """Validate one durable event and require canonical public text forms."""
439
- if not isinstance(payload, dict):
440
- raise FeedbackValidationError("feedback event must be an object")
441
- _reject_unknown_keys(payload, _ALLOWED_EVENT_TOP, "feedback event")
442
- if payload.get("contract") != EVENT_CONTRACT:
443
- raise FeedbackValidationError("Unsupported feedback event contract")
444
-
445
- site_id = normalize_site_id(payload.get("site_id"))
446
- page_id = normalize_page_id(payload.get("page_id"))
447
- page_revision = normalize_page_revision(payload.get("page_revision", ""))
448
- raw_feedback = payload.get("feedback")
449
- if not isinstance(raw_feedback, dict):
450
- raise FeedbackValidationError("feedback event feedback must be an object")
451
- _reject_unknown_keys(
452
- raw_feedback,
453
- _ALLOWED_EVENT_FEEDBACK,
454
- "feedback event.feedback",
455
- )
456
- feedback_id = _feedback_id(raw_feedback.get("id"))
457
- rating = _rating(raw_feedback.get("rating"))
458
- mode = _mode(raw_feedback.get("mode"))
459
-
460
- raw_contributor = raw_feedback.get("contributor")
461
- if raw_contributor == "Anonymous":
462
- contributor = "Anonymous"
463
- else:
464
- contributor = _public_credit(raw_contributor)
465
- if contributor.lower() == "anonymous":
466
- raise FeedbackValidationError(
467
- "feedback event contributor reserves 'Anonymous' for the anonymity sentinel"
468
- )
469
- if not contributor:
470
- raise FeedbackValidationError(
471
- "feedback event contributor must be 'Anonymous' or a non-empty public credit"
472
- )
473
- if contributor != raw_contributor:
474
- raise FeedbackValidationError(
475
- "feedback event contributor must already be canonicalized"
476
- )
477
-
478
- comment = _comment(raw_feedback.get("comment", ""))
479
- if "comment" in raw_feedback and comment != raw_feedback.get("comment"):
480
- raise FeedbackValidationError(
481
- "feedback event comment must already be canonicalized",
482
- )
483
- if mode == "quick":
484
- if rating not in {-1, 1}:
485
- raise FeedbackValidationError(
486
- "quick feedback rating must be -1 or +1",
487
- )
488
- if comment or contributor != "Anonymous":
489
- raise FeedbackValidationError(
490
- "quick feedback event must not contain comment or public credit",
491
- )
492
-
493
- normalized_feedback: dict[str, Any] = {
494
- "id": feedback_id,
495
- "rating": rating,
496
- "mode": mode,
497
- "contributor": contributor,
498
- }
499
- if comment:
500
- normalized_feedback["comment"] = comment
501
- normalized: dict[str, Any] = {
502
- "contract": EVENT_CONTRACT,
503
- "site_id": site_id,
504
- "page_id": page_id,
505
- "feedback": normalized_feedback,
506
- }
507
- if page_revision:
508
- normalized["page_revision"] = page_revision
509
- return normalized
510
-
511
-
512
- def canonical_event_bytes(event: dict[str, Any]) -> bytes:
513
- """Return deterministic bytes for durable event equality checks."""
514
- normalized = parse_feedback_event(event)
515
- return (
516
- json.dumps(
517
- normalized,
518
- ensure_ascii=False,
519
- sort_keys=True,
520
- separators=(",", ":"),
521
- )
522
- + "\n"
523
- ).encode("utf-8")
524
-
525
-
526
- def repository_event_bytes(event: dict[str, Any]) -> bytes:
527
- """
528
- Return deterministic human-readable JSON for repository event files.
529
-
530
- Repository presentation is intentionally separate from
531
- :func:`canonical_event_bytes`: whitespace must never become feedback-event
532
- identity or alter request/idempotency commitments.
533
- """
534
- normalized = parse_feedback_event(event)
535
- return (
536
- json.dumps(
537
- normalized,
538
- ensure_ascii=False,
539
- indent=2,
540
- sort_keys=True,
541
- )
542
- + "\n"
543
- ).encode("utf-8")
544
-
545
-
546
- def validate_request_hash(value: Any) -> str:
547
- """Validate a lowercase SHA-256 request commitment."""
548
- if not isinstance(value, str) or not _REQUEST_HASH_RE.fullmatch(value):
549
- raise FeedbackValidationError(
550
- "request_hash must be 64 lowercase hexadecimal characters",
551
- )
552
- return value
553
-
554
-
555
- def page_digest(site_id: str, page_id: str) -> str:
556
- """Return a filesystem-safe opaque page bucket without hiding page_id in JSON."""
557
- site = normalize_site_id(site_id)
558
- page = normalize_page_id(page_id)
559
- raw = f"{site}\0{page}".encode()
560
- return hashlib.sha256(raw).hexdigest()[:24]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
_page_feedback/_service/__init__.py DELETED
@@ -1,20 +0,0 @@
1
- """Server-only generic page-feedback service API."""
2
-
3
- from ._config import (
4
- FeedbackServiceConfig,
5
- FeedbackServiceConfigError,
6
- StorageTarget,
7
- load_service_config,
8
- parse_storage_targets,
9
- )
10
- from ._core import FeedbackServiceUnavailable, PageFeedbackService
11
-
12
- __all__ = [
13
- "FeedbackServiceConfig",
14
- "FeedbackServiceConfigError",
15
- "FeedbackServiceUnavailable",
16
- "PageFeedbackService",
17
- "StorageTarget",
18
- "load_service_config",
19
- "parse_storage_targets",
20
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
_page_feedback/_service/_config.py DELETED
@@ -1,775 +0,0 @@
1
- """Server-only feedback storage/review policy parsing."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import os
7
- import re
8
- from dataclasses import dataclass
9
- from pathlib import Path, PurePosixPath
10
- from typing import Any, Mapping
11
-
12
- from .._contracts import normalize_page_id, normalize_page_revision, normalize_site_id
13
-
14
- PROVIDERS = frozenset({"sqlite", "github", "huggingface", "gitlab", "bitbucket"})
15
- IMPLEMENTED_PROVIDERS = frozenset({"sqlite", "github"})
16
- DEFAULT_FEEDBACK_STORAGE_TARGETS: tuple = ()
17
- ROLES = frozenset({"primary", "mirror"})
18
- REVIEW_MODES = frozenset({"disabled", "sqlite", "provider-pr"})
19
- PAGE_AUTHORITY_CONTRACT = "page.feedback-authority.v1"
20
- _MAX_PAGE_AUTHORITY_BYTES = 4 * 1024 * 1024
21
- _MAX_PAGE_AUTHORITY_SITES = 32
22
- _MAX_PAGE_AUTHORITY_PAGES_PER_SITE = 250_000
23
- _ALLOWED_TARGET_KEYS = frozenset(
24
- {
25
- "id",
26
- "label",
27
- "authority",
28
- "provider",
29
- "role",
30
- "repo",
31
- "branch",
32
- "paths",
33
- "token_env",
34
- "database",
35
- "expose_links",
36
- }
37
- )
38
- _ALLOWED_PATH_KEYS = frozenset({"feedback"})
39
- _REPO_RE = re.compile(r"[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}\Z")
40
- _ENV_RE = re.compile(r"[A-Z][A-Z0-9_]{0,127}\Z")
41
- _ALLOWED_EXACT_TOKEN_ENVS = frozenset(
42
- {
43
- "GITHUB_TOKEN",
44
- "HF_TOKEN",
45
- "GITLAB_TOKEN",
46
- "BITBUCKET_TOKEN",
47
- },
48
- )
49
- _FEEDBACK_TOKEN_ENV_RE = re.compile(r"FEEDBACK_[A-Z0-9_]*TOKEN[A-Z0-9_]*\Z")
50
- _ALLOWED_TOKEN_PREFIXES = ("AI_RECORD_STORAGE_TOKEN_",)
51
-
52
-
53
- class FeedbackServiceConfigError(ValueError):
54
- pass
55
-
56
-
57
- def _reject_duplicate_json_keys(pairs):
58
- result = {}
59
- for key, value in pairs:
60
- if key in result:
61
- raise FeedbackServiceConfigError(
62
- f"duplicate JSON field in feedback configuration: {key}",
63
- )
64
- result[key] = value
65
- return result
66
-
67
-
68
- def _reject_nonfinite_json(value: str) -> None:
69
- raise FeedbackServiceConfigError(
70
- f"non-finite JSON number is not allowed in feedback configuration: {value}"
71
- )
72
-
73
-
74
- @dataclass(frozen=True)
75
- class StorageTarget:
76
- id: str
77
- label: str
78
- provider: str
79
- role: str
80
- authority: str = "feedback"
81
- repo: str = ""
82
- branch: str = "main"
83
- feedback_path: str = "feedback"
84
- token_env: tuple[str, ...] = ()
85
- database: str = ""
86
- expose_links: bool = False
87
-
88
- def resolve_token(self, env: Mapping[str, str] = os.environ) -> tuple[str, str]:
89
- for name in self.token_env:
90
- raw_value = env.get(name, "")
91
- if raw_value in (None, ""):
92
- continue
93
- if not isinstance(raw_value, str):
94
- raise FeedbackServiceConfigError(
95
- f"feedback credential value in {name!r} must be a string"
96
- )
97
- value = raw_value
98
- _len = len(value) > 4096 # ruff: ignore[magic-value-comparison]
99
- if _len or any(
100
- not 33 <= ord(ch) <= 126 # ruff: ignore[magic-value-comparison]
101
- for ch in value
102
- ):
103
- raise FeedbackServiceConfigError(
104
- f"feedback credential value in {name!r} is invalid"
105
- )
106
- return value, name
107
- return "", ""
108
-
109
-
110
- @dataclass(frozen=True)
111
- class FeedbackServiceConfig:
112
- review_mode: str
113
- targets: tuple[StorageTarget, ...]
114
- allowed_site_ids: tuple[str, ...]
115
- page_authority: tuple[tuple[str, str, str], ...] | None
116
- max_body_bytes: int
117
- rate_limit_per_hour: int
118
- mirror_timeout_seconds: float
119
-
120
- @property
121
- def primary(self) -> StorageTarget | None:
122
- return next(
123
- (target for target in self.targets if target.role == "primary"),
124
- None,
125
- )
126
-
127
- @property
128
- def mirrors(self) -> tuple[StorageTarget, ...]:
129
- return tuple(target for target in self.targets if target.role == "mirror")
130
-
131
-
132
- def _token_envs(value: Any, provider: str) -> tuple[str, ...]:
133
- if value in (None, ""):
134
- defaults = {
135
- "github": (
136
- "FEEDBACK_GITHUB_TOKEN",
137
- "GITHUB_TOKEN",
138
- "AI_RECORD_STORAGE_TOKEN_GITHUB_MIRROR",
139
- ),
140
- "huggingface": (
141
- "FEEDBACK_HF_TOKEN",
142
- "HF_TOKEN",
143
- "AI_RECORD_STORAGE_TOKEN_HF_PRIMARY",
144
- ),
145
- "gitlab": (
146
- "FEEDBACK_GITLAB_TOKEN",
147
- "GITLAB_TOKEN",
148
- ),
149
- "bitbucket": (
150
- "FEEDBACK_BITBUCKET_TOKEN",
151
- "BITBUCKET_TOKEN",
152
- ),
153
- "sqlite": (),
154
- }
155
- return defaults[provider]
156
- if isinstance(value, str):
157
- values = (value,)
158
- elif isinstance(value, (list, tuple)):
159
- values = tuple(value)
160
- else:
161
- values = ()
162
- if not values and value not in ([], ()):
163
- raise FeedbackServiceConfigError(
164
- "token_env must be a string or list of environment-variable names",
165
- )
166
- if provider != "sqlite" and not values:
167
- raise FeedbackServiceConfigError(
168
- f"{provider} storage target requires at least one token_env credential alias",
169
- )
170
- if len(values) > 8: # ruff: ignore[magic-value-comparison]
171
- raise FeedbackServiceConfigError(
172
- "token_env contains too many credential aliases",
173
- )
174
- result = []
175
- for item in values:
176
- if not isinstance(item, str):
177
- raise FeedbackServiceConfigError(
178
- "token_env entries must be environment-variable name strings",
179
- )
180
- name = item.strip()
181
- if not _ENV_RE.fullmatch(name):
182
- raise FeedbackServiceConfigError(
183
- "token_env contains an invalid environment-variable name",
184
- )
185
- if (
186
- name not in _ALLOWED_EXACT_TOKEN_ENVS
187
- and not _FEEDBACK_TOKEN_ENV_RE.fullmatch(name)
188
- and not name.startswith(_ALLOWED_TOKEN_PREFIXES)
189
- ):
190
- raise FeedbackServiceConfigError(
191
- f"token_env {name!r} is outside the feedback credential allowlist",
192
- )
193
- if name not in result:
194
- result.append(name)
195
- return tuple(result)
196
-
197
-
198
- def _target_string(
199
- raw: dict[str, Any], key: str, *, default: str = "", allow_empty: bool = True
200
- ) -> str:
201
- value = raw.get(key)
202
- if value is None:
203
- value = default
204
- if not isinstance(value, str):
205
- raise FeedbackServiceConfigError(f"storage target {key} must be a string")
206
- try:
207
- value.encode("utf-8", "strict")
208
- except UnicodeError as exc:
209
- raise FeedbackServiceConfigError(
210
- f"storage target {key} contains invalid Unicode"
211
- ) from exc
212
- text = value.strip()
213
- if not allow_empty and not text:
214
- raise FeedbackServiceConfigError(f"storage target {key} must not be empty")
215
- return text
216
-
217
-
218
- def _safe_feedback_path(value: Any) -> str:
219
- if not isinstance(value, str):
220
- raise FeedbackServiceConfigError("feedback path must be a string")
221
- try:
222
- value.encode("utf-8", "strict")
223
- except UnicodeError as exc:
224
- raise FeedbackServiceConfigError(
225
- "feedback path contains invalid Unicode",
226
- ) from exc
227
- text = value.strip()
228
- if (
229
- not text
230
- or len(text) > 512 # ruff: ignore[magic-value-comparison]
231
- or text.startswith("/")
232
- or text.endswith("/")
233
- or "\\" in text
234
- or any(
235
- ord(ch) < 32 or ord(ch) == 127 # ruff: ignore[magic-value-comparison]
236
- for ch in text
237
- )
238
- ):
239
- raise FeedbackServiceConfigError("feedback path is invalid")
240
- path = PurePosixPath(text)
241
- if (
242
- path.is_absolute()
243
- or any(part in {"", ".", ".."} for part in path.parts)
244
- or str(path) != text
245
- ):
246
- raise FeedbackServiceConfigError(
247
- "feedback path must be a canonical safe relative POSIX path"
248
- )
249
- return text
250
-
251
-
252
- def _parse_target( # ruff: ignore[too-many-branches]
253
- raw: Any,
254
- ) -> StorageTarget:
255
- if not isinstance(raw, dict):
256
- raise FeedbackServiceConfigError(
257
- "each feedback storage target must be an object",
258
- )
259
- forbidden = {
260
- "token",
261
- "secret",
262
- "password",
263
- "authorization",
264
- } & set(raw)
265
- if forbidden:
266
- raise FeedbackServiceConfigError(
267
- "storage targets may name token_env values but may not contain literal secrets",
268
- )
269
- unknown = sorted(set(raw) - _ALLOWED_TARGET_KEYS)
270
- if unknown:
271
- raise FeedbackServiceConfigError(
272
- (
273
- "feedback storage target contains unsupported field(s): "
274
- + ", ".join(unknown)
275
- ),
276
- )
277
- target_id = _target_string(raw, "id", allow_empty=False)
278
- if not re.fullmatch(r"[a-z0-9][a-z0-9._-]{0,63}", target_id):
279
- raise FeedbackServiceConfigError(
280
- "storage target id must be a lowercase stable identifier",
281
- )
282
- authority = _target_string(
283
- raw,
284
- "authority",
285
- default="feedback",
286
- allow_empty=False,
287
- ).lower()
288
- if authority != "feedback":
289
- raise FeedbackServiceConfigError(
290
- "storage target authority must be 'feedback'",
291
- )
292
- provider = _target_string(raw, "provider", allow_empty=False).lower()
293
- role = _target_string(raw, "role", allow_empty=False).lower()
294
- if provider not in PROVIDERS:
295
- raise FeedbackServiceConfigError(
296
- f"unsupported feedback storage provider {provider!r}",
297
- )
298
- if role not in ROLES:
299
- raise FeedbackServiceConfigError(
300
- "storage target role must be primary or mirror",
301
- )
302
- label = _target_string(raw, "label", default=target_id, allow_empty=False)
303
- _len = len(label) > 120 # ruff: ignore[magic-value-comparison]
304
- if _len or any(
305
- ord(ch) < 32 or ord(ch) == 127 # ruff: ignore[magic-value-comparison]
306
- for ch in label
307
- ):
308
- raise FeedbackServiceConfigError(
309
- "storage target label is invalid",
310
- )
311
- repo = _target_string(raw, "repo")
312
- if provider != "sqlite":
313
- if not _REPO_RE.fullmatch(repo):
314
- raise FeedbackServiceConfigError(
315
- f"{provider} storage target requires owner/repo",
316
- )
317
- owner, repository = repo.split("/", 1)
318
- if owner in {".", ".."} or repository in {".", ".."}:
319
- raise FeedbackServiceConfigError(
320
- f"{provider} storage target requires a canonical owner/repo",
321
- )
322
- branch = _target_string(
323
- raw,
324
- "branch",
325
- default="main",
326
- allow_empty=False,
327
- )
328
- if (
329
- len(branch) > 128 # ruff: ignore[magic-value-comparison]
330
- or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/-]{0,127}", branch)
331
- or ".." in branch
332
- or "//" in branch
333
- or branch.endswith(("/", ".", ".lock"))
334
- or any(
335
- part in {"", ".", ".."} or part.endswith(".lock")
336
- for part in branch.split("/")
337
- )
338
- ):
339
- raise FeedbackServiceConfigError(
340
- "storage target branch is invalid",
341
- )
342
- if provider == "github" and branch == "feedback":
343
- raise FeedbackServiceConfigError(
344
- "GitHub base branch 'feedback' conflicts with the deterministic feedback/<nonce> review namespace",
345
- )
346
- paths = raw.get("paths", None)
347
- if paths is None:
348
- paths = {}
349
- if not isinstance(paths, dict):
350
- raise FeedbackServiceConfigError(
351
- "storage target paths must be an object",
352
- )
353
- unknown_paths = sorted(set(paths) - _ALLOWED_PATH_KEYS)
354
- if unknown_paths:
355
- raise FeedbackServiceConfigError(
356
- (
357
- "storage target paths contains unsupported field(s): "
358
- + ", ".join(unknown_paths)
359
- ),
360
- )
361
- feedback_path = _safe_feedback_path(paths.get("feedback", "feedback"))
362
- database = _target_string(raw, "database")
363
- if len(database) > 1024 or any( # ruff: ignore[magic-value-comparison]
364
- ord(ch) < 32 or ord(ch) == 127 # ruff: ignore[magic-value-comparison]
365
- for ch in database
366
- ):
367
- raise FeedbackServiceConfigError(
368
- "sqlite database path is invalid",
369
- )
370
- if provider == "sqlite" and not database:
371
- database = "feedback.sqlite3"
372
- if provider == "sqlite" and database == ":memory:":
373
- raise FeedbackServiceConfigError(
374
- "sqlite :memory: is unsupported because feedback opens bounded independent connections",
375
- )
376
- if provider != "sqlite" and database:
377
- raise FeedbackServiceConfigError(
378
- "database is only valid for sqlite targets",
379
- )
380
- if provider == "sqlite":
381
- if repo:
382
- raise FeedbackServiceConfigError(
383
- "repo is not valid for sqlite targets",
384
- )
385
- if "branch" in raw:
386
- raise FeedbackServiceConfigError(
387
- "branch is not valid for sqlite targets",
388
- )
389
- if "paths" in raw:
390
- raise FeedbackServiceConfigError(
391
- "paths is not valid for sqlite targets",
392
- )
393
- if raw.get("token_env") not in (None, "", [], ()):
394
- raise FeedbackServiceConfigError(
395
- "token_env is not valid for sqlite targets",
396
- )
397
- if raw.get("expose_links", False) not in (False, None):
398
- raise FeedbackServiceConfigError(
399
- "expose_links is not valid for sqlite targets",
400
- )
401
- token_env = _token_envs(raw.get("token_env"), provider)
402
- expose_links = raw.get("expose_links", False)
403
- if not isinstance(expose_links, bool):
404
- raise FeedbackServiceConfigError(
405
- "storage target expose_links must be a boolean",
406
- )
407
- return StorageTarget(
408
- id=target_id,
409
- label=label,
410
- provider=provider,
411
- role=role,
412
- authority=authority,
413
- repo=repo,
414
- branch=branch,
415
- feedback_path=feedback_path,
416
- token_env=token_env,
417
- database=database,
418
- expose_links=expose_links,
419
- )
420
-
421
-
422
- def parse_storage_targets(raw: Any) -> tuple[StorageTarget, ...]:
423
- if raw in (None, "", []):
424
- return ()
425
- if isinstance(raw, str):
426
- try:
427
- raw_size = len(raw.encode("utf-8", "strict"))
428
- except UnicodeError as exc:
429
- raise FeedbackServiceConfigError(
430
- "FEEDBACK_STORAGE_TARGETS contains invalid Unicode"
431
- ) from exc
432
- if raw_size > 64 * 1024:
433
- raise FeedbackServiceConfigError(
434
- "FEEDBACK_STORAGE_TARGETS exceeds the 64 KiB safety limit"
435
- )
436
- try:
437
- raw = json.loads(
438
- raw,
439
- object_pairs_hook=_reject_duplicate_json_keys,
440
- parse_constant=_reject_nonfinite_json,
441
- )
442
- except FeedbackServiceConfigError:
443
- raise
444
- except (json.JSONDecodeError, ValueError, RecursionError) as exc:
445
- raise FeedbackServiceConfigError(
446
- "FEEDBACK_STORAGE_TARGETS is not valid JSON",
447
- ) from exc
448
- if raw == []:
449
- return ()
450
- _len = len(raw) > 5 # ruff: ignore[magic-value-comparison]
451
- if not isinstance(raw, list) or _len:
452
- raise FeedbackServiceConfigError(
453
- "feedback storage targets must be a list with at most five entries",
454
- )
455
- targets = tuple(_parse_target(item) for item in raw)
456
- if len({target.id for target in targets}) != len(targets):
457
- raise FeedbackServiceConfigError(
458
- "feedback storage target ids must be unique",
459
- )
460
- primaries = [target for target in targets if target.role == "primary"]
461
- if len(primaries) != 1:
462
- raise FeedbackServiceConfigError(
463
- "feedback storage topology requires exactly one primary target",
464
- )
465
- return targets
466
-
467
-
468
- def _bounded_int(value: Any, default: int, low: int, high: int, *, name: str) -> int:
469
- if value in (None, ""):
470
- return default
471
- if isinstance(value, bool):
472
- raise FeedbackServiceConfigError(f"{name} must be an integer")
473
- try:
474
- parsed = int(value)
475
- except (TypeError, ValueError) as exc:
476
- raise FeedbackServiceConfigError(f"{name} must be an integer") from exc
477
- if not low <= parsed <= high:
478
- raise FeedbackServiceConfigError(f"{name} must be between {low} and {high}")
479
- return parsed
480
-
481
-
482
- def _bounded_float(
483
- value: Any, default: float, low: float, high: float, *, name: str
484
- ) -> float:
485
- if value in (None, ""):
486
- return default
487
- if isinstance(value, bool):
488
- raise FeedbackServiceConfigError(f"{name} must be a number")
489
- try:
490
- parsed = float(value)
491
- except (TypeError, ValueError) as exc:
492
- raise FeedbackServiceConfigError(f"{name} must be a number") from exc
493
- if not low <= parsed <= high:
494
- raise FeedbackServiceConfigError(f"{name} must be between {low} and {high}")
495
- return parsed
496
-
497
-
498
- def _load_page_authority( # ruff: ignore[too-many-branches]
499
- value: Any,
500
- ) -> tuple[tuple[str, str, str], ...] | None:
501
- """Load an optional bounded server-side page/revision authority manifest."""
502
- if value in (None, ""):
503
- return None
504
- if not isinstance(value, str):
505
- raise FeedbackServiceConfigError(
506
- "FEEDBACK_PAGE_AUTHORITY_FILE must be a filesystem path string"
507
- )
508
- try:
509
- value.encode("utf-8", "strict")
510
- except UnicodeError as exc:
511
- raise FeedbackServiceConfigError(
512
- "FEEDBACK_PAGE_AUTHORITY_FILE contains invalid Unicode"
513
- ) from exc
514
- path_text = value.strip()
515
- _len = len(path_text) > 2048 # ruff: ignore[magic-value-comparison]
516
- if (
517
- not path_text
518
- or _len
519
- or any(
520
- ord(ch) < 32 or ord(ch) == 127 # ruff: ignore[magic-value-comparison]
521
- for ch in path_text
522
- )
523
- ):
524
- raise FeedbackServiceConfigError(
525
- "FEEDBACK_PAGE_AUTHORITY_FILE path is invalid",
526
- )
527
- path = Path(path_text)
528
- try:
529
- data = path.read_bytes()
530
- except OSError as exc:
531
- raise FeedbackServiceConfigError(
532
- "unable to read FEEDBACK_PAGE_AUTHORITY_FILE"
533
- ) from exc
534
- if len(data) > _MAX_PAGE_AUTHORITY_BYTES:
535
- raise FeedbackServiceConfigError(
536
- "FEEDBACK_PAGE_AUTHORITY_FILE exceeds the 4 MiB safety limit"
537
- )
538
- try:
539
- payload = json.loads(
540
- data,
541
- object_pairs_hook=_reject_duplicate_json_keys,
542
- parse_constant=_reject_nonfinite_json,
543
- )
544
- except FeedbackServiceConfigError:
545
- raise
546
- except (
547
- UnicodeDecodeError,
548
- json.JSONDecodeError,
549
- ValueError,
550
- RecursionError,
551
- ) as exc:
552
- raise FeedbackServiceConfigError(
553
- "FEEDBACK_PAGE_AUTHORITY_FILE is not valid UTF-8 JSON",
554
- ) from exc
555
- if (
556
- not isinstance(payload, dict)
557
- or payload.get("contract") != PAGE_AUTHORITY_CONTRACT
558
- ):
559
- raise FeedbackServiceConfigError(
560
- "FEEDBACK_PAGE_AUTHORITY_FILE has an unsupported contract",
561
- )
562
- unknown = sorted(set(payload) - {"contract", "sites"})
563
- if unknown:
564
- raise FeedbackServiceConfigError(
565
- (
566
- "feedback page authority contains unsupported field(s): "
567
- + ", ".join(unknown)
568
- ),
569
- )
570
- sites = payload.get("sites")
571
- if not isinstance(sites, dict) or len(sites) > _MAX_PAGE_AUTHORITY_SITES:
572
- raise FeedbackServiceConfigError(
573
- "feedback page authority sites must be a bounded object",
574
- )
575
- rows: list[tuple[str, str, str]] = []
576
- for raw_site_id, pages in sites.items():
577
- try:
578
- site_id = normalize_site_id(raw_site_id)
579
- except ValueError as exc:
580
- raise FeedbackServiceConfigError(
581
- "feedback page authority contains an invalid site_id",
582
- ) from exc
583
- if site_id != raw_site_id:
584
- raise FeedbackServiceConfigError(
585
- "feedback page authority site_id must already be canonicalized",
586
- )
587
- if (
588
- not isinstance(pages, dict)
589
- or len(pages) > _MAX_PAGE_AUTHORITY_PAGES_PER_SITE
590
- ):
591
- raise FeedbackServiceConfigError(
592
- "feedback page authority pages must be a bounded object",
593
- )
594
- for raw_page_id, raw_revision in pages.items():
595
- try:
596
- page_id = normalize_page_id(raw_page_id)
597
- revision = normalize_page_revision(raw_revision)
598
- except ValueError as exc:
599
- raise FeedbackServiceConfigError(
600
- "feedback page authority contains an invalid page or revision"
601
- ) from exc
602
- if page_id != raw_page_id or revision != raw_revision:
603
- raise FeedbackServiceConfigError(
604
- "feedback page authority values must already be canonicalized"
605
- )
606
- rows.append((site_id, page_id, revision))
607
- return tuple(rows)
608
-
609
-
610
- def _allowed_site_ids(value: Any) -> tuple[str, ...]:
611
- """
612
- Parse an optional exact server-side site allowlist.
613
-
614
- The browser-provided ``site_id`` is descriptive event data, not authority.
615
- Deployments can bind one feedback service to one or more known site IDs with
616
- ``FEEDBACK_ALLOWED_SITE_IDS``. Blank preserves generic multi-site compatibility.
617
- """
618
- if value in (None, ""):
619
- return ()
620
- if not isinstance(value, str):
621
- raise FeedbackServiceConfigError(
622
- "FEEDBACK_ALLOWED_SITE_IDS must be a comma-separated string"
623
- )
624
- try:
625
- raw_size = len(value.encode("utf-8", "strict"))
626
- except UnicodeError as exc:
627
- raise FeedbackServiceConfigError(
628
- "FEEDBACK_ALLOWED_SITE_IDS contains invalid Unicode"
629
- ) from exc
630
- if raw_size > 4096: # ruff: ignore[magic-value-comparison]
631
- raise FeedbackServiceConfigError(
632
- "FEEDBACK_ALLOWED_SITE_IDS exceeds the 4 KiB safety limit"
633
- )
634
- parts = [part.strip() for part in value.split(",")]
635
- if any(not part for part in parts):
636
- raise FeedbackServiceConfigError(
637
- "FEEDBACK_ALLOWED_SITE_IDS contains an empty site identifier"
638
- )
639
- if len(parts) > 32: # ruff: ignore[magic-value-comparison]
640
- raise FeedbackServiceConfigError(
641
- "FEEDBACK_ALLOWED_SITE_IDS contains too many site identifiers"
642
- )
643
- result: list[str] = []
644
- for part in parts:
645
- try:
646
- site_id = normalize_site_id(part)
647
- except ValueError as exc:
648
- raise FeedbackServiceConfigError(
649
- "FEEDBACK_ALLOWED_SITE_IDS contains an invalid site identifier"
650
- ) from exc
651
- if site_id not in result:
652
- result.append(site_id)
653
- return tuple(result)
654
-
655
-
656
- def load_service_config(
657
- env: Mapping[str, str] = os.environ,
658
- ) -> FeedbackServiceConfig:
659
- mode = (
660
- str(
661
- env.get("FEEDBACK_REVIEW_MODE", "disabled") or "disabled",
662
- )
663
- .strip()
664
- .lower()
665
- )
666
- if mode not in REVIEW_MODES:
667
- raise FeedbackServiceConfigError(
668
- f"FEEDBACK_REVIEW_MODE must be one of {sorted(REVIEW_MODES)}",
669
- )
670
- raw_targets = env.get("FEEDBACK_STORAGE_TARGETS", "")
671
- if raw_targets:
672
- targets = parse_storage_targets(raw_targets)
673
- elif mode == "sqlite":
674
- targets = parse_storage_targets(
675
- [
676
- {
677
- "id": "sqlite-primary",
678
- "label": "SQLite Feedback",
679
- "authority": "feedback",
680
- "provider": "sqlite",
681
- "role": "primary",
682
- "database": (
683
- str(
684
- env.get("FEEDBACK_SQLITE_PATH", "feedback.sqlite3")
685
- or "feedback.sqlite3"
686
- ).strip()
687
- ),
688
- }
689
- ]
690
- )
691
- elif (
692
- mode == "provider-pr"
693
- and str(env.get("FEEDBACK_GITHUB_REPOSITORY", "") or "").strip()
694
- ):
695
- targets = parse_storage_targets(
696
- [
697
- {
698
- "id": "github-primary",
699
- "label": "GitHub Feedback Review",
700
- "authority": "feedback",
701
- "provider": "github",
702
- "role": "primary",
703
- "repo": str(env["FEEDBACK_GITHUB_REPOSITORY"]).strip(),
704
- "branch": (
705
- str(
706
- env.get("FEEDBACK_GITHUB_DEFAULT_BRANCH", "main") or "main",
707
- ).strip()
708
- ),
709
- "paths": {
710
- "feedback": env.get("FEEDBACK_GITHUB_PATH", "feedback"),
711
- },
712
- "token_env": None,
713
- "expose_links": False,
714
- }
715
- ]
716
- )
717
- else:
718
- targets = DEFAULT_FEEDBACK_STORAGE_TARGETS
719
- if mode == "disabled" and targets:
720
- raise FeedbackServiceConfigError(
721
- "feedback storage targets must be empty when FEEDBACK_REVIEW_MODE=disabled"
722
- )
723
- if mode != "disabled" and not targets:
724
- raise FeedbackServiceConfigError(
725
- "enabled feedback review mode requires one primary storage target"
726
- )
727
- primary = next((target for target in targets if target.role == "primary"), None)
728
- if mode == "sqlite" and primary and primary.provider != "sqlite":
729
- raise FeedbackServiceConfigError(
730
- "FEEDBACK_REVIEW_MODE=sqlite requires a sqlite primary target"
731
- )
732
- if mode == "sqlite" and any(target.provider != "sqlite" for target in targets):
733
- raise FeedbackServiceConfigError(
734
- "FEEDBACK_REVIEW_MODE=sqlite is local-only and requires every storage target to use sqlite"
735
- )
736
- if mode == "provider-pr" and primary and primary.provider == "sqlite":
737
- raise FeedbackServiceConfigError(
738
- "FEEDBACK_REVIEW_MODE=provider-pr requires a provider primary target"
739
- )
740
- if mode != "disabled":
741
- unavailable = sorted(
742
- {target.provider for target in targets} - IMPLEMENTED_PROVIDERS
743
- )
744
- if unavailable:
745
- raise FeedbackServiceConfigError(
746
- "configured feedback provider adapter(s) are not implemented in this release: "
747
- + ", ".join(unavailable)
748
- )
749
- return FeedbackServiceConfig(
750
- review_mode=mode,
751
- targets=targets,
752
- allowed_site_ids=_allowed_site_ids(env.get("FEEDBACK_ALLOWED_SITE_IDS")),
753
- page_authority=_load_page_authority(env.get("FEEDBACK_PAGE_AUTHORITY_FILE")),
754
- max_body_bytes=_bounded_int(
755
- env.get("FEEDBACK_PAGE_MAX_BODY_BYTES"),
756
- 16 * 1024,
757
- 1024,
758
- 65536,
759
- name="FEEDBACK_PAGE_MAX_BODY_BYTES",
760
- ),
761
- rate_limit_per_hour=_bounded_int(
762
- env.get("FEEDBACK_PAGE_RATE_LIMIT_PER_HOUR"),
763
- 20,
764
- 1,
765
- 240,
766
- name="FEEDBACK_PAGE_RATE_LIMIT_PER_HOUR",
767
- ),
768
- mirror_timeout_seconds=_bounded_float(
769
- env.get("FEEDBACK_MIRROR_TIMEOUT_SECONDS"),
770
- 8.0,
771
- 0.5,
772
- 15.0,
773
- name="FEEDBACK_MIRROR_TIMEOUT_SECONDS",
774
- ),
775
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
_page_feedback/_service/_core.py DELETED
@@ -1,307 +0,0 @@
1
- """Provider-neutral feedback service coordinator."""
2
-
3
- from __future__ import annotations
4
-
5
- import asyncio
6
- import os
7
- import sqlite3
8
- from pathlib import Path
9
- from typing import Any, Mapping
10
-
11
- from .._contracts import (
12
- FeedbackConflictError,
13
- FeedbackValidationError,
14
- build_feedback_event,
15
- feedback_request_hash,
16
- parse_feedback_request,
17
- )
18
- from ._config import (
19
- FeedbackServiceConfig,
20
- FeedbackServiceConfigError,
21
- StorageTarget,
22
- load_service_config,
23
- )
24
- from ._github import ProviderWriteError, submit_github_review
25
- from ._sqlite import SQLiteFeedbackStore
26
-
27
-
28
- class FeedbackServiceUnavailable( # ruff: ignore[error-suffix-on-exception-name]
29
- RuntimeError,
30
- ):
31
- def __init__(self, code: str, message: str) -> None:
32
- super().__init__(message)
33
- self.code = code
34
-
35
-
36
- class PageFeedbackService:
37
- """Accept one minimal feedback event and coordinate primary/mirror writes."""
38
-
39
- def __init__(
40
- self,
41
- config: FeedbackServiceConfig,
42
- *,
43
- credential_env: Mapping[str, str] | None = None,
44
- ) -> None:
45
- self.config = config
46
- self._credential_env = os.environ if credential_env is None else credential_env
47
- self._page_authority = (
48
- None
49
- if config.page_authority is None
50
- else {
51
- (site_id, page_id): revision
52
- for site_id, page_id, revision in config.page_authority
53
- }
54
- )
55
-
56
- @classmethod
57
- def from_env(cls, env=None) -> PageFeedbackService:
58
- source = os.environ if env is None else env
59
- return cls(load_service_config(source), credential_env=source)
60
-
61
- async def _write_target(
62
- self,
63
- target: StorageTarget,
64
- *,
65
- request: dict[str, Any],
66
- event: dict[str, Any],
67
- request_hash: str,
68
- http_client=None,
69
- ) -> dict[str, Any]:
70
- if target.provider == "sqlite":
71
- store = SQLiteFeedbackStore(Path(target.database))
72
- try:
73
- return await asyncio.to_thread(
74
- store.put,
75
- feedback_id=event["feedback"]["id"],
76
- request_hash=request_hash,
77
- event=event,
78
- )
79
- except FeedbackConflictError:
80
- raise
81
- except (OSError, sqlite3.Error) as exc:
82
- raise FeedbackServiceUnavailable(
83
- "sqlite_write_failed", "SQLite feedback storage is unavailable"
84
- ) from exc
85
- if target.provider == "github":
86
- try:
87
- token, _token_name = target.resolve_token(self._credential_env)
88
- except FeedbackServiceConfigError as exc:
89
- raise FeedbackServiceUnavailable(
90
- "credential_invalid",
91
- "GitHub feedback credential is invalid",
92
- ) from exc
93
- if http_client is None:
94
- try:
95
- import httpx # ruff: ignore[import-outside-top-level]
96
- except ( # pragma: no cover - runtime dependency boundary
97
- Exception
98
- ) as exc:
99
- raise FeedbackServiceUnavailable(
100
- "http_client_unavailable",
101
- "GitHub feedback transport requires httpx",
102
- ) from exc
103
- timeout = httpx.Timeout(
104
- 20.0, connect=10.0, read=20.0, write=20.0, pool=10.0
105
- )
106
- async with httpx.AsyncClient(
107
- timeout=timeout, follow_redirects=False
108
- ) as client:
109
- return await submit_github_review(
110
- client=client,
111
- target=target,
112
- token=token,
113
- event=event,
114
- request_hash=request_hash,
115
- )
116
- return await submit_github_review(
117
- client=http_client,
118
- target=target,
119
- token=token,
120
- event=event,
121
- request_hash=request_hash,
122
- )
123
- raise FeedbackServiceUnavailable(
124
- "provider_not_implemented",
125
- f"Feedback provider {target.provider!r} is modeled but not implemented in this release",
126
- )
127
-
128
- def validate_request(
129
- self,
130
- payload: dict[str, Any],
131
- ) -> dict[str, Any]:
132
- """Normalize one request and enforce server-side submission authority."""
133
- request = parse_feedback_request(payload)
134
- if (
135
- self.config.allowed_site_ids
136
- and request["site_id"] not in self.config.allowed_site_ids
137
- ):
138
- raise FeedbackValidationError(
139
- "site_id is not authorized by this feedback service",
140
- code="site_not_allowed",
141
- )
142
- if self._page_authority is not None:
143
- authority_key = (request["site_id"], request["page_id"])
144
- if authority_key not in self._page_authority:
145
- raise FeedbackValidationError(
146
- "page_id is not authorized by this feedback service",
147
- code="page_not_allowed",
148
- )
149
- expected_revision = self._page_authority[authority_key]
150
- if (
151
- expected_revision
152
- and request.get("page_revision", "") != expected_revision
153
- ):
154
- raise FeedbackValidationError(
155
- "page_revision does not match the server-side page authority",
156
- code="page_revision_mismatch",
157
- )
158
- return request
159
-
160
- @staticmethod
161
- def _valid_target_receipt(
162
- target: StorageTarget,
163
- receipt: Any,
164
- *,
165
- feedback_id: str,
166
- request_hash: str,
167
- ) -> bool:
168
- return (
169
- isinstance(receipt, dict)
170
- and receipt.get("status") in {"accepted", "replay"}
171
- and receipt.get("provider") == target.provider
172
- and receipt.get("feedback_id") == feedback_id
173
- and receipt.get("request_hash") == request_hash
174
- )
175
-
176
- async def submit(
177
- self,
178
- payload: dict[str, Any],
179
- *,
180
- http_client=None,
181
- ) -> dict[str, Any]:
182
- if self.config.review_mode == "disabled":
183
- raise FeedbackServiceUnavailable(
184
- "feedback_disabled",
185
- "Generic page feedback is disabled on this service",
186
- )
187
- request = self.validate_request(payload)
188
- request_hash = feedback_request_hash(request)
189
- event = build_feedback_event(request)
190
- primary = self.config.primary
191
- if primary is None:
192
- raise FeedbackServiceUnavailable(
193
- "primary_missing",
194
- "No primary feedback storage target is configured",
195
- )
196
- try:
197
- primary_receipt = await self._write_target(
198
- primary,
199
- request=request,
200
- event=event,
201
- request_hash=request_hash,
202
- http_client=http_client,
203
- )
204
- except ProviderWriteError as exc:
205
- raise FeedbackServiceUnavailable(exc.code, str(exc)) from exc
206
-
207
- if not self._valid_target_receipt(
208
- primary,
209
- primary_receipt,
210
- feedback_id=event["feedback"]["id"],
211
- request_hash=request_hash,
212
- ):
213
- raise FeedbackServiceUnavailable(
214
- "provider_receipt_invalid",
215
- "Primary feedback provider returned an invalid receipt",
216
- )
217
- primary_status = primary_receipt["status"]
218
-
219
- mirror_status: dict[str, str] = {}
220
-
221
- async def write_mirror(mirror: StorageTarget) -> tuple[str, str]:
222
- try:
223
- mirror_receipt = await self._write_target(
224
- mirror,
225
- request=request,
226
- event=event,
227
- request_hash=request_hash,
228
- http_client=http_client,
229
- )
230
- if not self._valid_target_receipt(
231
- mirror,
232
- mirror_receipt,
233
- feedback_id=event["feedback"]["id"],
234
- request_hash=request_hash,
235
- ):
236
- return mirror.id, "degraded"
237
- return mirror.id, "ok"
238
- except asyncio.CancelledError:
239
- raise
240
- except (
241
- FeedbackConflictError,
242
- FeedbackServiceUnavailable,
243
- ProviderWriteError,
244
- OSError,
245
- ):
246
- # Mirrors are durability only. Primary authority already accepted.
247
- return mirror.id, "degraded"
248
-
249
- tasks = {
250
- asyncio.create_task(write_mirror(mirror)): mirror.id
251
- for mirror in self.config.mirrors
252
- }
253
- if tasks:
254
- pending: set[asyncio.Task] = set()
255
- try:
256
- done, pending = await asyncio.wait(
257
- tasks, timeout=self.config.mirror_timeout_seconds
258
- )
259
- for task in done:
260
- mirror_id = tasks[task]
261
- try:
262
- resolved_id, status = task.result()
263
- except asyncio.CancelledError:
264
- # A child mirror can be cancelled independently by its
265
- # transport/runtime. Primary authority has already
266
- # succeeded, so treat that as durability degradation;
267
- # parent cancellation is handled by the outer finally.
268
- resolved_id, status = mirror_id, "degraded"
269
- except Exception: # ruff: ignore[blind-except]
270
- resolved_id, status = mirror_id, "degraded"
271
- mirror_status[resolved_id] = status
272
- for task in pending:
273
- mirror_status[tasks[task]] = "degraded"
274
- finally:
275
- # If the request task itself is cancelled after primary authority
276
- # has committed, never leave mirror writes detached from their
277
- # parent lifecycle. Cancelling and gathering unfinished children
278
- # makes shutdown/disconnect behavior deterministic; a later retry
279
- # can safely resume every provider with the same event nonce.
280
- unfinished = [task for task in tasks if not task.done()]
281
- for task in unfinished:
282
- task.cancel()
283
- if unfinished:
284
- await asyncio.gather(*unfinished, return_exceptions=True)
285
- return {
286
- "ok": True,
287
- "contract": "page.feedback-receipt.v1",
288
- "feedback_id": event["feedback"]["id"],
289
- "status": primary_status,
290
- "provider": primary.provider,
291
- "request_hash": request_hash,
292
- "review_url": (
293
- primary_receipt.get("review_url", "") if primary.expose_links else ""
294
- ),
295
- "mirrors": {
296
- mirror_id: mirror_status[mirror_id]
297
- for mirror_id in sorted(mirror_status)
298
- },
299
- }
300
-
301
-
302
- __all__ = [
303
- "FeedbackConflictError",
304
- "FeedbackServiceConfigError",
305
- "FeedbackServiceUnavailable",
306
- "PageFeedbackService",
307
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
_page_feedback/_service/_github.py DELETED
@@ -1,714 +0,0 @@
1
- """
2
- GitHub provider-review adapter for immutable feedback JSON events.
3
-
4
- The adapter is deliberately retry-oriented. A network interruption may happen after
5
- GitHub has created a branch, committed the immutable event, or opened a pull
6
- request. Replaying the same feedback ID verifies each durable step and resumes at
7
- the first missing one instead of treating a partially completed submission as a
8
- permanent conflict.
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- import base64
14
- import json
15
- from typing import Any
16
- from urllib.parse import quote, urlsplit
17
-
18
- from .. import __version__
19
- from .._contracts import (
20
- FeedbackConflictError,
21
- FeedbackValidationError,
22
- canonical_event_bytes,
23
- decode_feedback_event,
24
- feedback_event_request_hash,
25
- page_digest,
26
- parse_feedback_event,
27
- repository_event_bytes,
28
- validate_request_hash,
29
- )
30
- from ._config import StorageTarget
31
-
32
- _MAX_RESPONSE = 256 * 1024
33
- _MAX_RESPONSE_CHUNKS = 1024
34
- _REQUEST_TIMEOUT_SECONDS = 20.0
35
-
36
-
37
- class ProviderWriteError(RuntimeError):
38
- """Bounded provider failure safe to surface as a stable service code."""
39
-
40
- def __init__(self, code: str, message: str) -> None:
41
- super().__init__(message)
42
- self.code = code
43
-
44
-
45
- async def _bounded_json( # ruff: ignore[too-many-branches]
46
- client,
47
- method: str,
48
- url: str,
49
- *,
50
- headers: dict[str, str],
51
- json_body: Any = None,
52
- params: dict[str, str] | None = None,
53
- ) -> tuple[int, Any]:
54
- """Perform one bounded GitHub API request even with an unbounded shared client."""
55
- response = None
56
- try:
57
- request = client.build_request(
58
- method,
59
- url,
60
- headers=headers,
61
- json=json_body,
62
- params=params,
63
- timeout=_REQUEST_TIMEOUT_SECONDS,
64
- )
65
- response = await client.send(request, stream=True, follow_redirects=False)
66
- length = response.headers.get("content-length")
67
- if length:
68
- try:
69
- if int(length) > _MAX_RESPONSE:
70
- raise ProviderWriteError(
71
- "provider_response_too_large",
72
- "GitHub response exceeded the safety limit",
73
- )
74
- except ValueError:
75
- pass
76
- chunks: list[bytes] = []
77
- total = 0
78
- chunk_count = 0
79
- async for chunk in response.aiter_bytes():
80
- chunk_count += 1
81
- if chunk_count > _MAX_RESPONSE_CHUNKS:
82
- raise ProviderWriteError(
83
- "provider_response_too_fragmented",
84
- "GitHub response exceeded the response-frame safety limit",
85
- )
86
- total += len(chunk)
87
- if total > _MAX_RESPONSE:
88
- raise ProviderWriteError(
89
- "provider_response_too_large",
90
- "GitHub response exceeded the safety limit",
91
- )
92
- chunks.append(chunk)
93
- raw = b"".join(chunks)
94
- if not raw:
95
- payload: Any = {}
96
- else:
97
- try:
98
- payload = json.loads(raw)
99
- except (
100
- UnicodeDecodeError,
101
- json.JSONDecodeError,
102
- ValueError,
103
- RecursionError,
104
- ) as exc:
105
- raise ProviderWriteError(
106
- "provider_response_invalid",
107
- "GitHub returned invalid JSON",
108
- ) from exc
109
- return int(response.status_code), payload
110
- except ProviderWriteError:
111
- raise
112
- except Exception as exc:
113
- # Do not leak low-level network details/tokens to the public route. The
114
- # exception remains chained for server-side diagnostics.
115
- raise ProviderWriteError(
116
- "provider_transport_failed",
117
- "GitHub feedback transport failed",
118
- ) from exc
119
- finally:
120
- if response is not None:
121
- try: # ruff: ignore[suppressible-exception]
122
- await response.aclose()
123
- except Exception: # ruff: ignore[blind-except]
124
- pass
125
-
126
-
127
- def _headers(token: str) -> dict[str, str]:
128
- return {
129
- "Accept": "application/vnd.github+json",
130
- "Authorization": f"Bearer {token}",
131
- "X-GitHub-Api-Version": "2022-11-28",
132
- "User-Agent": f"sphinx-feedback/{__version__}",
133
- }
134
-
135
-
136
- def _event_path(target: StorageTarget, event: dict[str, Any]) -> str:
137
- bucket = page_digest(event["site_id"], event["page_id"])
138
- return f"{target.feedback_path}/pages/{bucket}/{event['feedback']['id']}.json"
139
-
140
-
141
- def _review_page_text(page_id: str, *, maximum: int | None = None) -> str:
142
- """Return a provider-display-safe page label with no Markdown mention syntax."""
143
- text = quote(page_id, safe="/._-")
144
- if maximum is not None:
145
- text = text[:maximum]
146
- return text
147
-
148
-
149
- def _branch(event: dict[str, Any]) -> str:
150
- # Use the full 192-bit event nonce. Branch identity is event provenance, not
151
- # participant identity, and full entropy avoids unnecessary prefix collisions.
152
- return "feedback/" + event["feedback"]["id"].removeprefix("feedback-")
153
-
154
-
155
- def _object_sha(payload: Any, *, code: str) -> str:
156
- try:
157
- sha = str(payload["object"]["sha"])
158
- except Exception as exc:
159
- raise ProviderWriteError(
160
- code,
161
- "GitHub branch response was incomplete",
162
- ) from exc
163
- _len = len(sha) != 40 # ruff: ignore[magic-value-comparison]
164
- if _len or any(ch not in "0123456789abcdefABCDEF" for ch in sha):
165
- raise ProviderWriteError(
166
- code,
167
- "GitHub branch response contained an invalid object id",
168
- )
169
- return sha
170
-
171
-
172
- def _review_url(value: Any, *, repo: str | None = None) -> str:
173
- text = str(value or "").strip()
174
- try:
175
- parsed = urlsplit(text)
176
- port = parsed.port
177
- except ValueError as exc:
178
- raise ProviderWriteError(
179
- "review_lookup_invalid", "GitHub feedback review URL was invalid"
180
- ) from exc
181
- if (
182
- parsed.scheme != "https"
183
- or (parsed.hostname or "").lower() != "github.com"
184
- or parsed.username
185
- or parsed.password
186
- or parsed.query
187
- or parsed.fragment
188
- or port not in (None, 443)
189
- ):
190
- raise ProviderWriteError(
191
- "review_lookup_invalid", "GitHub feedback review URL was invalid"
192
- )
193
- if repo is not None:
194
- prefix = f"/{repo}/pull/"
195
- number = parsed.path.removeprefix(prefix)
196
- if not parsed.path.startswith(prefix) or not number.isdigit():
197
- raise ProviderWriteError(
198
- "review_lookup_invalid",
199
- "GitHub feedback review URL did not belong to the configured repository",
200
- )
201
- return text
202
-
203
-
204
- async def _existing_review_url(
205
- client,
206
- *,
207
- api: str,
208
- headers: dict[str, str],
209
- repo: str,
210
- branch: str,
211
- base_branch: str,
212
- ) -> str | None:
213
- owner = repo.split("/", 1)[0]
214
- status, pulls = await _bounded_json(
215
- client,
216
- "GET",
217
- f"{api}/pulls",
218
- headers=headers,
219
- params={"state": "all", "head": f"{owner}:{branch}", "base": base_branch},
220
- )
221
- if status != 200: # ruff: ignore[magic-value-comparison]
222
- raise ProviderWriteError(
223
- "review_lookup_failed",
224
- "Existing GitHub feedback review could not be verified",
225
- )
226
- if not isinstance(pulls, list):
227
- raise ProviderWriteError(
228
- "review_lookup_invalid",
229
- "GitHub feedback review lookup returned an invalid response",
230
- )
231
- if not pulls:
232
- return None
233
- first = pulls[0]
234
- if not isinstance(first, dict):
235
- raise ProviderWriteError(
236
- "review_lookup_invalid",
237
- "GitHub feedback review lookup returned an invalid entry",
238
- )
239
- return _review_url(first.get("html_url"), repo=repo)
240
-
241
-
242
- async def _branch_comparison(
243
- client,
244
- *,
245
- api: str,
246
- headers: dict[str, str],
247
- base_branch: str,
248
- branch: str,
249
- ) -> dict[str, Any]:
250
- status, comparison = await _bounded_json(
251
- client,
252
- "GET",
253
- f"{api}/compare/{quote(base_branch, safe='')}...{quote(branch, safe='')}",
254
- headers=headers,
255
- )
256
- _status = status != 200 # ruff: ignore[magic-value-comparison]
257
- if _status or not isinstance(comparison, dict):
258
- raise ProviderWriteError(
259
- "existing_branch_unverifiable",
260
- "Existing GitHub feedback branch could not be compared safely",
261
- )
262
- ahead_by = comparison.get("ahead_by")
263
- if isinstance(ahead_by, bool) or not isinstance(ahead_by, int) or ahead_by < 0:
264
- raise ProviderWriteError(
265
- "existing_branch_unverifiable",
266
- "GitHub branch comparison was incomplete",
267
- )
268
- return comparison
269
-
270
-
271
- async def _branch_has_no_unique_commits(
272
- client,
273
- *,
274
- api: str,
275
- headers: dict[str, str],
276
- base_branch: str,
277
- branch: str,
278
- ) -> bool:
279
- comparison = await _branch_comparison(
280
- client,
281
- api=api,
282
- headers=headers,
283
- base_branch=base_branch,
284
- branch=branch,
285
- )
286
- return comparison["ahead_by"] == 0
287
-
288
-
289
- async def _verify_review_branch_scope(
290
- client,
291
- *,
292
- api: str,
293
- headers: dict[str, str],
294
- base_branch: str,
295
- branch: str,
296
- path: str,
297
- event: dict[str, Any],
298
- review_exists: bool,
299
- ) -> None:
300
- """
301
- Require the review branch to contain exactly one immutable feedback file.
302
-
303
- A deterministic branch name is resumable, but it is also a security boundary:
304
- an existing branch must never be allowed to smuggle unrelated repository changes
305
- into the pull request merely because the expected feedback JSON is present.
306
- """
307
- existing_bytes = await _existing_event_bytes(
308
- client, api=api, headers=headers, path=path, branch=branch
309
- )
310
- if not _event_content_matches(existing_bytes, event):
311
- if existing_bytes is None:
312
- raise FeedbackConflictError(
313
- "feedback review branch is missing the expected immutable event"
314
- )
315
- raise FeedbackConflictError(
316
- "feedback_id was already used for different feedback content"
317
- )
318
-
319
- comparison = await _branch_comparison(
320
- client,
321
- api=api,
322
- headers=headers,
323
- base_branch=base_branch,
324
- branch=branch,
325
- )
326
- if comparison["ahead_by"] == 0:
327
- # After a reviewed pull request is merged, the deterministic branch can
328
- # compare equal to the base. Preserve idempotent replay only when a review
329
- # already exists and the immutable event is now present on the base branch.
330
- if not review_exists:
331
- raise FeedbackConflictError(
332
- "feedback review branch contains an unexpected commit history"
333
- )
334
- base_bytes = await _existing_event_bytes(
335
- client, api=api, headers=headers, path=path, branch=base_branch
336
- )
337
- if not _event_content_matches(base_bytes, event):
338
- raise FeedbackConflictError(
339
- "feedback review branch no longer proves the expected reviewed event"
340
- )
341
- return
342
- if comparison["ahead_by"] != 1:
343
- raise FeedbackConflictError(
344
- "feedback review branch contains an unexpected commit history"
345
- )
346
- files = comparison.get("files")
347
- if not isinstance(files, list) or len(files) != 1 or not isinstance(files[0], dict):
348
- raise FeedbackConflictError(
349
- "feedback review branch contains unexpected repository changes"
350
- )
351
- changed = files[0]
352
- if changed.get("filename") != path or changed.get("status") != "added":
353
- raise FeedbackConflictError(
354
- "feedback review branch contains unexpected repository changes"
355
- )
356
-
357
-
358
- async def _branch_exists(
359
- client, *, api: str, headers: dict[str, str], branch: str
360
- ) -> bool:
361
- status, payload = await _bounded_json(
362
- client,
363
- "GET",
364
- f"{api}/git/ref/heads/{quote(branch, safe='')}",
365
- headers=headers,
366
- )
367
- if status == 404: # ruff: ignore[magic-value-comparison]
368
- return False
369
- if status != 200: # ruff: ignore[magic-value-comparison]
370
- raise ProviderWriteError(
371
- "existing_branch_unverifiable",
372
- "Existing GitHub feedback branch could not be verified",
373
- )
374
- _object_sha(payload, code="existing_branch_invalid")
375
- return True
376
-
377
-
378
- async def _existing_event_bytes(
379
- client,
380
- *,
381
- api: str,
382
- headers: dict[str, str],
383
- path: str,
384
- branch: str,
385
- ) -> bytes | None:
386
- content_status, existing = await _bounded_json(
387
- client,
388
- "GET",
389
- f"{api}/contents/{quote(path, safe='/')}",
390
- headers=headers,
391
- params={"ref": branch},
392
- )
393
- if content_status == 404: # ruff: ignore[magic-value-comparison]
394
- return None
395
- _content_status = content_status != 200 # ruff: ignore[magic-value-comparison]
396
- if _content_status or not isinstance(existing, dict):
397
- raise ProviderWriteError(
398
- "existing_branch_unverifiable",
399
- "Existing GitHub feedback event could not be verified",
400
- )
401
- try:
402
- return base64.b64decode(
403
- str(existing.get("content", "")).replace("\n", ""), validate=True
404
- )
405
- except Exception as exc:
406
- raise ProviderWriteError(
407
- "existing_content_invalid",
408
- "Existing GitHub feedback event could not be verified",
409
- ) from exc
410
-
411
-
412
- def _event_content_matches(existing_bytes: bytes | None, event: dict[str, Any]) -> bool:
413
- """Compare repository JSON by validated event semantics, never whitespace."""
414
- if existing_bytes is None:
415
- return False
416
- try:
417
- existing_event = decode_feedback_event(existing_bytes)
418
- expected_event = parse_feedback_event(event)
419
- except FeedbackValidationError:
420
- return False
421
- return canonical_event_bytes(existing_event) == canonical_event_bytes(
422
- expected_event,
423
- )
424
-
425
-
426
- async def submit_github_review( # ruff: ignore[too-many-branches]
427
- *,
428
- client,
429
- target: StorageTarget,
430
- token: str,
431
- event: dict[str, Any],
432
- request_hash: str,
433
- ) -> dict[str, Any]:
434
- """Idempotently ensure branch, immutable event, and provider review exist."""
435
- if not token:
436
- raise ProviderWriteError(
437
- "credential_missing", "GitHub feedback credential is not configured"
438
- )
439
- event = parse_feedback_event(event)
440
- validate_request_hash(request_hash)
441
- if request_hash != feedback_event_request_hash(event):
442
- raise ProviderWriteError(
443
- "request_commitment_mismatch",
444
- "GitHub feedback request commitment does not match the durable event",
445
- )
446
- repo = target.repo
447
- api = "https://api.github.com/repos/" + repo
448
- headers = _headers(token)
449
- branch = _branch(event)
450
- if branch == target.branch:
451
- raise ProviderWriteError(
452
- "branch_namespace_conflict",
453
- "GitHub feedback review branch collides with the configured base branch",
454
- )
455
- path = _event_path(target, event)
456
- # Repository files are intentionally pretty-printed for human review.
457
- # Idempotency/equality remains semantic so pre-v0.6.1 compact files and
458
- # harmless formatting changes do not become false content conflicts.
459
- event_bytes = repository_event_bytes(event)
460
-
461
- status, base_ref = await _bounded_json(
462
- client,
463
- "GET",
464
- f"{api}/git/ref/heads/{quote(target.branch, safe='')}",
465
- headers=headers,
466
- )
467
- if status != 200: # ruff: ignore[magic-value-comparison]
468
- raise ProviderWriteError(
469
- "base_ref_unavailable", "GitHub feedback base branch could not be resolved"
470
- )
471
- base_sha = _object_sha(base_ref, code="base_ref_invalid")
472
-
473
- branch_created = False
474
- try:
475
- status, _created = await _bounded_json(
476
- client,
477
- "POST",
478
- f"{api}/git/refs",
479
- headers=headers,
480
- json_body={"ref": "refs/heads/" + branch, "sha": base_sha},
481
- )
482
- except ProviderWriteError as exc:
483
- if exc.code != "provider_transport_failed":
484
- raise
485
- # Broken pipe after GitHub accepted branch creation: reconcile before
486
- # asking the browser to retry the whole workflow.
487
- try:
488
- if await _branch_exists(client, api=api, headers=headers, branch=branch):
489
- status = 422
490
- else:
491
- raise exc # ruff: ignore[verbose-raise]
492
- except ProviderWriteError:
493
- raise exc # ruff: ignore[raise-without-from-inside-except]
494
- if status == 201: # ruff: ignore[magic-value-comparison]
495
- branch_created = True
496
- elif status == 422: # ruff: ignore[magic-value-comparison]
497
- if not await _branch_exists(client, api=api, headers=headers, branch=branch):
498
- raise ProviderWriteError(
499
- "branch_create_failed",
500
- "GitHub rejected the feedback branch and no resumable branch exists",
501
- )
502
- else:
503
- raise ProviderWriteError(
504
- "branch_create_failed", "GitHub feedback review branch could not be created"
505
- )
506
-
507
- event_present = False
508
- if not branch_created:
509
- existing_bytes = await _existing_event_bytes(
510
- client, api=api, headers=headers, path=path, branch=branch
511
- )
512
- if existing_bytes is not None:
513
- if not _event_content_matches(existing_bytes, event):
514
- raise FeedbackConflictError(
515
- "feedback_id was already used for different feedback content"
516
- )
517
- event_present = True
518
- elif not await _branch_has_no_unique_commits(
519
- client,
520
- api=api,
521
- headers=headers,
522
- base_branch=target.branch,
523
- branch=branch,
524
- ):
525
- raise FeedbackConflictError(
526
- "feedback review branch exists without the expected immutable event"
527
- )
528
-
529
- if not event_present:
530
- try:
531
- status, _content = await _bounded_json(
532
- client,
533
- "PUT",
534
- f"{api}/contents/{quote(path, safe='/')}",
535
- headers=headers,
536
- json_body={
537
- "message": f"Add page feedback {event['feedback']['id']}",
538
- "content": base64.b64encode(event_bytes).decode("ascii"),
539
- "branch": branch,
540
- },
541
- )
542
- except ProviderWriteError as exc:
543
- if exc.code != "provider_transport_failed":
544
- raise
545
- # The commit may have succeeded before the connection failed. Verify
546
- # the immutable bytes once; otherwise preserve the ambiguous error so
547
- # a later retry can safely resume the same deterministic branch.
548
- try:
549
- existing_bytes = await _existing_event_bytes(
550
- client, api=api, headers=headers, path=path, branch=branch
551
- )
552
- except ProviderWriteError:
553
- raise exc # ruff: ignore[raise-without-from-inside-except]
554
- if _event_content_matches(existing_bytes, event):
555
- status = 201
556
- else:
557
- raise exc # ruff: ignore[verbose-raise]
558
- if status not in {200, 201}:
559
- # Concurrent same-event submissions can race between the existence
560
- # check and PUT. Re-read immutable content before treating an
561
- # explicit provider rejection as failure.
562
- existing_bytes = await _existing_event_bytes(
563
- client, api=api, headers=headers, path=path, branch=branch
564
- )
565
- if _event_content_matches(existing_bytes, event):
566
- status = 201
567
- elif existing_bytes is not None:
568
- raise FeedbackConflictError(
569
- "feedback_id was already used for different feedback content"
570
- )
571
- else:
572
- raise ProviderWriteError(
573
- "content_write_failed",
574
- "GitHub feedback event could not be committed",
575
- )
576
-
577
- existing_review = await _existing_review_url(
578
- client,
579
- api=api,
580
- headers=headers,
581
- repo=repo,
582
- branch=branch,
583
- base_branch=target.branch,
584
- )
585
- await _verify_review_branch_scope(
586
- client,
587
- api=api,
588
- headers=headers,
589
- base_branch=target.branch,
590
- branch=branch,
591
- path=path,
592
- event=event,
593
- review_exists=existing_review is not None,
594
- )
595
- if existing_review is not None:
596
- return {
597
- "status": "replay",
598
- "provider": "github",
599
- "feedback_id": event["feedback"]["id"],
600
- "review_url": existing_review if target.expose_links else "",
601
- "request_hash": request_hash,
602
- }
603
-
604
- rating = int(event["feedback"]["rating"])
605
- signed = f"{rating:+d}" if rating else "0"
606
- title_page = _review_page_text(event["page_id"], maximum=100)
607
- body_page = _review_page_text(event["page_id"])
608
- body = (
609
- "Automated page-feedback review.\n\n"
610
- f"Request commitment: `{request_hash}`\n\n"
611
- f"Page: `{body_page}`\n\n"
612
- "The reviewed JSON contains no implicit browser, device, account, network, "
613
- "referrer, or telemetry fields."
614
- )
615
- try:
616
- status, pull = await _bounded_json(
617
- client,
618
- "POST",
619
- f"{api}/pulls",
620
- headers=headers,
621
- json_body={
622
- "title": f"[Feedback] {signed} · {title_page}",
623
- "head": branch,
624
- "base": target.branch,
625
- "body": body,
626
- },
627
- )
628
- except ProviderWriteError as exc:
629
- if exc.code != "provider_transport_failed":
630
- raise
631
- try:
632
- existing_review = await _existing_review_url(
633
- client,
634
- api=api,
635
- headers=headers,
636
- repo=repo,
637
- branch=branch,
638
- base_branch=target.branch,
639
- )
640
- except ProviderWriteError:
641
- raise exc # ruff: ignore[raise-without-from-inside-except]
642
- if existing_review is None:
643
- raise exc # ruff: ignore[verbose-raise]
644
- await _verify_review_branch_scope(
645
- client,
646
- api=api,
647
- headers=headers,
648
- base_branch=target.branch,
649
- branch=branch,
650
- path=path,
651
- event=event,
652
- review_exists=True,
653
- )
654
- return {
655
- "status": "replay",
656
- "provider": "github",
657
- "feedback_id": event["feedback"]["id"],
658
- "review_url": existing_review if target.expose_links else "",
659
- "request_hash": request_hash,
660
- }
661
- if status not in {200, 201}:
662
- # A provider race/non-success may still have opened the review. Reconcile
663
- # once for every non-success status, not only the common 422 case.
664
- existing_review = await _existing_review_url(
665
- client,
666
- api=api,
667
- headers=headers,
668
- repo=repo,
669
- branch=branch,
670
- base_branch=target.branch,
671
- )
672
- if existing_review is not None:
673
- await _verify_review_branch_scope(
674
- client,
675
- api=api,
676
- headers=headers,
677
- base_branch=target.branch,
678
- branch=branch,
679
- path=path,
680
- event=event,
681
- review_exists=True,
682
- )
683
- return {
684
- "status": "replay",
685
- "provider": "github",
686
- "feedback_id": event["feedback"]["id"],
687
- "review_url": existing_review if target.expose_links else "",
688
- "request_hash": request_hash,
689
- }
690
- raise ProviderWriteError(
691
- "review_open_failed", "GitHub feedback pull request could not be opened"
692
- )
693
- if not isinstance(pull, dict):
694
- raise ProviderWriteError(
695
- "review_open_invalid", "GitHub feedback pull request response was invalid"
696
- )
697
- review_url = _review_url(pull.get("html_url"), repo=repo)
698
- await _verify_review_branch_scope(
699
- client,
700
- api=api,
701
- headers=headers,
702
- base_branch=target.branch,
703
- branch=branch,
704
- path=path,
705
- event=event,
706
- review_exists=True,
707
- )
708
- return {
709
- "status": "accepted",
710
- "provider": "github",
711
- "feedback_id": event["feedback"]["id"],
712
- "review_url": review_url if target.expose_links else "",
713
- "request_hash": request_hash,
714
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
_page_feedback/_service/_sqlite.py DELETED
@@ -1,218 +0,0 @@
1
- """Durable SQLite feedback provider with conflict-safe idempotency."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import sqlite3
7
- import time
8
- from pathlib import Path
9
- from typing import Any
10
-
11
- from .._contracts import (
12
- FeedbackConflictError,
13
- canonical_event_bytes,
14
- feedback_event_request_hash,
15
- normalize_page_revision,
16
- normalize_site_id,
17
- parse_feedback_event,
18
- validate_request_hash,
19
- )
20
-
21
- _SCHEMA = """
22
- CREATE TABLE IF NOT EXISTS feedback_events (
23
- feedback_id TEXT PRIMARY KEY,
24
- request_hash TEXT NOT NULL,
25
- site_id TEXT NOT NULL,
26
- page_id TEXT NOT NULL,
27
- event_json TEXT NOT NULL
28
- ) WITHOUT ROWID;
29
- CREATE INDEX IF NOT EXISTS feedback_events_page_idx
30
- ON feedback_events(site_id, page_id);
31
- """
32
-
33
-
34
- class SQLiteFeedbackStore:
35
- """Small transactional event store; no participant/network identity columns."""
36
-
37
- def __init__(self, path: str | Path, *, busy_timeout_ms: int = 5000) -> None:
38
- self.path = Path(path)
39
- self.busy_timeout_ms = max(100, min(int(busy_timeout_ms), 30_000))
40
-
41
- def _setup_with_retry(self, conn: sqlite3.Connection) -> None:
42
- """
43
- Establish WAL/schema with bounded retry for first-open races.
44
-
45
- SQLite's busy timeout does not reliably cover every journal-mode transition.
46
- Concurrent first connections can therefore observe ``database is locked``
47
- before ordinary transactional retry behavior applies. Keep that race inside
48
- this initialization boundary rather than surfacing it as a failed feedback
49
- submission.
50
- """
51
- deadline = time.monotonic() + self.busy_timeout_ms / 1000
52
- delay = 0.005
53
- while True:
54
- try:
55
- mode = str(
56
- conn.execute("PRAGMA journal_mode").fetchone()[0],
57
- ).lower()
58
- if mode != "wal":
59
- mode = str(
60
- conn.execute("PRAGMA journal_mode=WAL").fetchone()[0],
61
- ).lower()
62
- if mode != "wal":
63
- raise sqlite3.OperationalError(
64
- "unable to enable WAL journal mode",
65
- )
66
- conn.executescript(_SCHEMA)
67
- return
68
- except sqlite3.OperationalError as exc: # ruff: ignore[try-except-in-loop]
69
- locked = "locked" in str(exc).lower() or "busy" in str(exc).lower()
70
- if not locked or time.monotonic() >= deadline:
71
- raise
72
- time.sleep(delay)
73
- delay = min(delay * 2, 0.1)
74
-
75
- def _connect(self) -> sqlite3.Connection:
76
- self.path.parent.mkdir(parents=True, exist_ok=True)
77
- conn = sqlite3.connect(
78
- self.path,
79
- timeout=self.busy_timeout_ms / 1000,
80
- isolation_level=None,
81
- )
82
- try:
83
- conn.execute(f"PRAGMA busy_timeout={self.busy_timeout_ms}")
84
- self._setup_with_retry(conn)
85
- conn.execute("PRAGMA synchronous=FULL")
86
- return conn
87
- except Exception:
88
- conn.close()
89
- raise
90
-
91
- def put(
92
- self, *, feedback_id: str, request_hash: str, event: dict[str, Any]
93
- ) -> dict[str, Any]:
94
- normalized = parse_feedback_event(event)
95
- if feedback_id != normalized["feedback"]["id"]:
96
- raise ValueError("feedback_id does not match the durable event")
97
- validate_request_hash(request_hash)
98
- expected_hash = feedback_event_request_hash(normalized)
99
- if request_hash != expected_hash:
100
- raise ValueError("request_hash does not match the durable feedback event")
101
- event_json = canonical_event_bytes(normalized).decode("utf-8").rstrip("\n")
102
- with self._connect() as conn:
103
- conn.execute("BEGIN IMMEDIATE")
104
- row = conn.execute(
105
- "SELECT request_hash, event_json FROM feedback_events WHERE feedback_id = ?",
106
- (feedback_id,),
107
- ).fetchone()
108
- if row is not None:
109
- if row[0] != request_hash or row[1] != event_json:
110
- conn.rollback()
111
- raise FeedbackConflictError(
112
- "feedback_id was already used for different feedback content"
113
- )
114
- conn.commit()
115
- return {
116
- "status": "replay",
117
- "provider": "sqlite",
118
- "feedback_id": feedback_id,
119
- "request_hash": request_hash,
120
- }
121
- conn.execute(
122
- "INSERT INTO feedback_events(feedback_id, request_hash, site_id, page_id, event_json) VALUES(?,?,?,?,?)",
123
- (
124
- feedback_id,
125
- request_hash,
126
- normalized["site_id"],
127
- normalized["page_id"],
128
- event_json,
129
- ),
130
- )
131
- conn.commit()
132
- return {
133
- "status": "accepted",
134
- "provider": "sqlite",
135
- "feedback_id": feedback_id,
136
- "request_hash": request_hash,
137
- }
138
-
139
- def events(self, *, site_id: str | None = None) -> list[dict[str, Any]]:
140
- if site_id is not None:
141
- site_id = normalize_site_id(site_id)
142
- with self._connect() as conn:
143
- if site_id is None:
144
- rows = conn.execute(
145
- "SELECT feedback_id, request_hash, site_id, page_id, event_json "
146
- "FROM feedback_events ORDER BY site_id, page_id, feedback_id"
147
- ).fetchall()
148
- else:
149
- rows = conn.execute(
150
- "SELECT feedback_id, request_hash, site_id, page_id, event_json "
151
- "FROM feedback_events WHERE site_id = ? ORDER BY page_id, feedback_id",
152
- (site_id,),
153
- ).fetchall()
154
- events: list[dict[str, Any]] = []
155
- for stored_id, stored_hash, stored_site, stored_page, raw_json in rows:
156
- try:
157
- decoded = json.loads(raw_json)
158
- event = parse_feedback_event(decoded)
159
- canonical = canonical_event_bytes(event).decode("utf-8").rstrip("\n")
160
- expected_hash = feedback_event_request_hash(event)
161
- except (json.JSONDecodeError, ValueError, RecursionError) as exc:
162
- raise sqlite3.DatabaseError(
163
- "stored feedback event is invalid",
164
- ) from exc
165
- if canonical != raw_json:
166
- raise sqlite3.DatabaseError(
167
- "stored feedback event is not canonical",
168
- )
169
- if (
170
- stored_id != event["feedback"]["id"]
171
- or stored_site != event["site_id"]
172
- or stored_page != event["page_id"]
173
- or stored_hash != expected_hash
174
- ):
175
- raise sqlite3.DatabaseError(
176
- "stored feedback event metadata does not match canonical event content"
177
- )
178
- events.append(event)
179
- return events
180
-
181
- def aggregate(
182
- self, *, site_id: str, page_revision: str | None = None
183
- ) -> dict[str, dict[str, int]]:
184
- site_id = normalize_site_id(site_id)
185
- revision_filter: str | None = None
186
- if page_revision not in (None, ""):
187
- revision_filter = normalize_page_revision(page_revision)
188
- if not revision_filter:
189
- raise ValueError(
190
- "revision-scoped aggregate requires a non-empty page_revision",
191
- )
192
- pages: dict[str, dict[str, int]] = {}
193
- for event in self.events(site_id=site_id):
194
- if (
195
- revision_filter is not None
196
- and event.get("page_revision", "") != revision_filter
197
- ):
198
- continue
199
- state = pages.setdefault(
200
- event["page_id"],
201
- {
202
- "count": 0,
203
- "score": 0,
204
- "positive_count": 0,
205
- "negative_count": 0,
206
- "neutral_count": 0,
207
- },
208
- )
209
- rating = int(event["feedback"]["rating"])
210
- state["count"] += 1
211
- state["score"] += rating
212
- if rating > 0:
213
- state["positive_count"] += 1
214
- elif rating < 0:
215
- state["negative_count"] += 1
216
- else:
217
- state["neutral_count"] += 1
218
- return pages
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
_page_feedback/_service/app.py DELETED
@@ -1,690 +0,0 @@
1
- """
2
- Dependency-free ASGI adapter for the generic page-feedback service.
3
-
4
- This module intentionally does not depend on FastAPI/Starlette. It is small enough to
5
- run directly under any ASGI server while the provider-neutral service remains reusable
6
- inside an existing application. The adapter stores no page-view data and keeps abuse
7
- control identity separate from durable feedback events.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import asyncio
13
- import hashlib
14
- import hmac
15
- import ipaddress
16
- import json
17
- import os
18
- import secrets
19
- import time
20
- from collections.abc import Mapping, Sequence
21
- from typing import Any
22
- from urllib.parse import urlsplit
23
-
24
- from .._contracts import (
25
- FeedbackConflictError,
26
- FeedbackValidationError,
27
- decode_feedback_request,
28
- feedback_request_hash,
29
- )
30
- from ._config import FeedbackServiceConfig, load_service_config
31
- from ._core import FeedbackServiceUnavailable, PageFeedbackService
32
-
33
- _ENDPOINT = "/v1/feedback"
34
- _WINDOW_SECONDS = 3600.0
35
- _MAX_ORIGINS = 64
36
- _MAX_RATE_IDENTITIES = 10_000
37
- _MAX_RETRY_KEYS = 50_000
38
- _MAX_SAME_REQUEST_RETRIES = 3
39
- _MAX_REQUEST_FRAMES = 256
40
- _MAX_TRUSTED_PROXY_CIDRS = 64
41
- _MAX_FORWARDED_HOPS = 32
42
- _MAX_SECURITY_HEADER_BYTES = 8192
43
- _RATE_PRUNE_INTERVAL_SECONDS = 60.0
44
-
45
-
46
- class FeedbackASGIConfigError(ValueError):
47
- """Raised for unsafe standalone-adapter configuration."""
48
-
49
-
50
- def _origin(value: str) -> str:
51
- text = str(value or "").strip()
52
- _len = len(text) > 2048 # ruff: ignore[magic-value-comparison]
53
- if (
54
- not text
55
- or _len
56
- or any(
57
- ord(ch) < 32 or ord(ch) == 127 # ruff: ignore[magic-value-comparison]
58
- for ch in text
59
- )
60
- ):
61
- raise FeedbackASGIConfigError(
62
- "feedback allowed origin is invalid",
63
- )
64
- try:
65
- parsed = urlsplit(text)
66
- port = parsed.port
67
- except ValueError as exc:
68
- raise FeedbackASGIConfigError(
69
- "feedback allowed origin is invalid",
70
- ) from exc
71
- host = (parsed.hostname or "").lower()
72
- local_http = parsed.scheme == "http" and host in {"127.0.0.1", "localhost", "::1"}
73
- if parsed.scheme != "https" and not local_http:
74
- raise FeedbackASGIConfigError(
75
- "feedback allowed origins must use HTTPS or localhost HTTP",
76
- )
77
- if (
78
- not host
79
- or parsed.username
80
- or parsed.password
81
- or parsed.path not in {"", "/"}
82
- or parsed.query
83
- or parsed.fragment
84
- ):
85
- raise FeedbackASGIConfigError(
86
- "feedback allowed origin must contain only scheme and authority",
87
- )
88
- if parsed.scheme == "https" and port not in (None, 443):
89
- raise FeedbackASGIConfigError(
90
- "feedback allowed HTTPS origins must use port 443",
91
- )
92
- authority = host
93
- if ":" in host and not host.startswith("["):
94
- authority = f"[{host}]"
95
- if (
96
- port is not None
97
- and not (
98
- parsed.scheme == "https" # lint
99
- and port == 443 # ruff: ignore[magic-value-comparison]
100
- )
101
- and not (
102
- parsed.scheme == "http" # lint
103
- and port == 80 # ruff: ignore[magic-value-comparison]
104
- )
105
- ):
106
- authority += f":{port}"
107
- return f"{parsed.scheme}://{authority}"
108
-
109
-
110
- def parse_allowed_origins(value: str | Sequence[str] | None) -> tuple[str, ...]:
111
- """Normalize an optional exact CORS allowlist; blank means no CORS headers."""
112
- if value in (None, ""):
113
- return ()
114
- if isinstance(value, Sequence) and not isinstance(value, str) and len(value) == 0:
115
- return ()
116
- if isinstance(value, str):
117
- items = [part.strip() for part in value.split(",") if part.strip()]
118
- elif isinstance(value, Sequence):
119
- items = list(value)
120
- else:
121
- raise FeedbackASGIConfigError(
122
- "feedback allowed origins must be a string or sequence",
123
- )
124
- if len(items) > _MAX_ORIGINS:
125
- raise FeedbackASGIConfigError(
126
- "feedback allowed origins contains too many entries",
127
- )
128
- result: list[str] = []
129
- for item in items:
130
- normalized = _origin(str(item))
131
- if normalized not in result:
132
- result.append(normalized)
133
- return tuple(result)
134
-
135
-
136
- def _singleton_header(scope: dict[str, Any], name: str) -> str:
137
- values: list[str] = []
138
- expected = name.lower()
139
- headers = scope.get("headers") or []
140
- if not isinstance(headers, (list, tuple)):
141
- raise FeedbackASGIConfigError("ASGI headers must be a sequence of byte pairs")
142
- for item in headers:
143
- _len = len(item) != 2 # ruff: ignore[magic-value-comparison]
144
- if not isinstance(item, (list, tuple)) or _len:
145
- raise FeedbackASGIConfigError("ASGI headers must contain byte pairs")
146
- key, value = item
147
- if not isinstance(key, bytes) or not isinstance(value, bytes):
148
- raise FeedbackASGIConfigError("ASGI header names and values must be bytes")
149
- if (
150
- len(key) > 256 # ruff: ignore[magic-value-comparison]
151
- or len(value) > _MAX_SECURITY_HEADER_BYTES
152
- or any(ch in key for ch in (0, 10, 13))
153
- or any(ch in value for ch in (0, 10, 13))
154
- ):
155
- raise FeedbackASGIConfigError("ASGI header framing is invalid")
156
- header_name = key.decode("latin-1").lower()
157
- header_value = value.decode("latin-1")
158
- if header_name == expected:
159
- values.append(header_value)
160
- if len(values) > 1:
161
- raise FeedbackASGIConfigError(f"duplicate {name} header is not allowed")
162
- return values[0] if values else ""
163
-
164
-
165
- def parse_trusted_proxy_cidrs( # ruff: ignore[too-many-branches]
166
- value: str | Sequence[str] | None,
167
- ) -> tuple[str, ...]:
168
- """
169
- Return canonical trusted-proxy CIDRs for X-Forwarded-For processing.
170
-
171
- Forwarded identity is ignored unless the direct ASGI peer belongs to one of
172
- these explicitly trusted networks. Networks must already be canonical (host
173
- bits are rejected), which makes deployment policy deterministic and auditable.
174
- """
175
- if value in (None, ""):
176
- return ()
177
- if isinstance(value, Sequence) and not isinstance(value, str) and len(value) == 0:
178
- return ()
179
- if isinstance(value, str):
180
- items = [part.strip() for part in value.split(",") if part.strip()]
181
- elif isinstance(value, Sequence):
182
- items = list(value)
183
- else:
184
- raise FeedbackASGIConfigError(
185
- "trusted proxy CIDRs must be a string or sequence",
186
- )
187
- if not items or len(items) > _MAX_TRUSTED_PROXY_CIDRS:
188
- raise FeedbackASGIConfigError(
189
- "trusted proxy CIDRs must contain 1..64 entries",
190
- )
191
- result: list[str] = []
192
- for item in items:
193
- if not isinstance(item, str):
194
- raise FeedbackASGIConfigError(
195
- "trusted proxy CIDR entries must be strings",
196
- )
197
- text = item.strip()
198
- _len = len(text) > 128 # ruff: ignore[magic-value-comparison]
199
- if (
200
- not text
201
- or _len
202
- or any(
203
- ord(ch) < 33 or ord(ch) > 126 # ruff: ignore[magic-value-comparison]
204
- for ch in text
205
- )
206
- ):
207
- raise FeedbackASGIConfigError("trusted proxy CIDR is invalid")
208
- try:
209
- network = ipaddress.ip_network(text, strict=True)
210
- except ValueError as exc:
211
- raise FeedbackASGIConfigError("trusted proxy CIDR is invalid") from exc
212
- if network.prefixlen == 0:
213
- raise FeedbackASGIConfigError(
214
- "trusted proxy CIDR must not trust an entire address family"
215
- )
216
- canonical = network.with_prefixlen
217
- if canonical != text:
218
- raise FeedbackASGIConfigError(
219
- "trusted proxy CIDR must already be canonical",
220
- )
221
- if canonical not in result:
222
- result.append(canonical)
223
-
224
- # Reject a collectively universal trust policy too. A literal /0 is not the
225
- # only way to trust an entire address family: two complementary /1 networks
226
- # (or several narrower networks) can collapse to the same /0. Treat that as
227
- # the same unsafe deployment mistake instead of letting spelling bypass the
228
- # guard above. Keep IPv4 and IPv6 separate because collapse_addresses does
229
- # not accept mixed address families.
230
- for version in (4, 6):
231
- networks = [
232
- ipaddress.ip_network(item, strict=True)
233
- for item in result
234
- if ipaddress.ip_network(item, strict=True).version == version
235
- ]
236
- if networks and any(
237
- network.prefixlen == 0 for network in ipaddress.collapse_addresses(networks)
238
- ):
239
- raise FeedbackASGIConfigError(
240
- "trusted proxy CIDRs must not collectively trust an entire address family"
241
- )
242
- return tuple(result)
243
-
244
-
245
- def _json_bytes(payload: dict[str, Any]) -> bytes:
246
- return json.dumps(
247
- payload,
248
- ensure_ascii=False,
249
- separators=(",", ":"),
250
- ).encode("utf-8")
251
-
252
-
253
- class FeedbackASGIApp:
254
- """Minimal standalone ASGI transport for :class:`PageFeedbackService`."""
255
-
256
- def __init__(
257
- self,
258
- config: FeedbackServiceConfig,
259
- *,
260
- allowed_origins: Sequence[str] = (),
261
- service: PageFeedbackService | None = None,
262
- trusted_proxy_cidrs: Sequence[str] = (),
263
- ) -> None:
264
- self.config = config
265
- self.service = service or PageFeedbackService(config)
266
- self.allowed_origins = parse_allowed_origins(allowed_origins)
267
- self.trusted_proxy_cidrs = parse_trusted_proxy_cidrs(trusted_proxy_cidrs)
268
- self._trusted_proxy_networks = tuple(
269
- ipaddress.ip_network(item, strict=True) for item in self.trusted_proxy_cidrs
270
- )
271
- self._rate_secret = secrets.token_bytes(32)
272
- self._rate: dict[str, tuple[int, float]] = {}
273
- self._rate_retries: dict[tuple[str, str], tuple[int, float]] = {}
274
- self._rate_lock = asyncio.Lock()
275
- self._rate_last_prune = 0.0
276
-
277
- def _cors_origin(self, scope: dict[str, Any]) -> str:
278
- origin = _singleton_header(scope, "origin").strip()
279
- if not origin or not self.allowed_origins:
280
- return ""
281
- try:
282
- normalized = _origin(origin)
283
- except FeedbackASGIConfigError:
284
- return "!"
285
- return normalized if normalized in self.allowed_origins else "!"
286
-
287
- def _is_trusted_proxy(
288
- self,
289
- address: ipaddress.IPv4Address | ipaddress.IPv6Address,
290
- ) -> bool:
291
- return any(address in network for network in self._trusted_proxy_networks)
292
-
293
- def _rate_address( # ruff: ignore[too-many-return-statements]
294
- self,
295
- scope: dict[str, Any],
296
- ) -> str:
297
- """
298
- Resolve the abuse-control address without trusting caller headers.
299
-
300
- Direct peers are authoritative by default. ``X-Forwarded-For`` is used
301
- only when the immediate peer is explicitly trusted, and the chain is
302
- walked from right to left until the first untrusted hop. Malformed
303
- forwarding metadata falls back to the direct peer, which can over-limit
304
- but cannot create attacker-controlled identities.
305
- """
306
- client = scope.get("client")
307
- if not isinstance(client, (list, tuple)) or not client:
308
- return "unknown"
309
- try:
310
- peer = ipaddress.ip_address(str(client[0] or ""))
311
- except ValueError:
312
- return "unknown"
313
- if not self._trusted_proxy_networks or not self._is_trusted_proxy(peer):
314
- return peer.compressed
315
- try:
316
- forwarded = _singleton_header(scope, "x-forwarded-for").strip()
317
- except FeedbackASGIConfigError:
318
- return peer.compressed
319
- if not forwarded:
320
- return peer.compressed
321
- parts = [part.strip() for part in forwarded.split(",")]
322
- if (
323
- not parts
324
- or len(parts) > _MAX_FORWARDED_HOPS
325
- or any(not part for part in parts)
326
- ):
327
- return peer.compressed
328
- try:
329
- chain = [ipaddress.ip_address(part) for part in parts]
330
- except ValueError:
331
- return peer.compressed
332
- current = peer
333
- for candidate in reversed(chain):
334
- if not self._is_trusted_proxy(current):
335
- break
336
- current = candidate
337
- return current.compressed
338
-
339
- def _rate_identity(self, scope: dict[str, Any]) -> str:
340
- address = self._rate_address(scope)
341
- message = f"page-feedback-asgi:v1:submit\0{address}".encode("utf-8", "replace")
342
- return hmac.new(self._rate_secret, message, hashlib.sha256).hexdigest()
343
-
344
- def _prune_rate_locked(self, now: float, *, force: bool = False) -> None:
345
- if not force and now - self._rate_last_prune < _RATE_PRUNE_INTERVAL_SECONDS:
346
- return
347
- stale = [
348
- key
349
- for key, (_count, start) in self._rate.items()
350
- if now - start >= _WINDOW_SECONDS
351
- ]
352
- for key in stale:
353
- self._rate.pop(key, None)
354
- stale_retries = [
355
- key
356
- for key, (_count, start) in self._rate_retries.items()
357
- if now - start >= _WINDOW_SECONDS
358
- ]
359
- for key in stale_retries:
360
- self._rate_retries.pop(key, None)
361
- self._rate_last_prune = now
362
-
363
- async def _consume_rate(self, identity: str, request_hash: str) -> bool:
364
- """
365
- Admit a new event or a bounded exact retry without tracking people.
366
-
367
- The main quota counts distinct request commitments. Once a commitment has
368
- been admitted, up to ``_MAX_SAME_REQUEST_RETRIES`` exact retries may pass
369
- without consuming another new-event slot. This preserves broken-pipe
370
- recovery even at a 1/hour quota while keeping retries bounded. The cache
371
- contains only the HMAC abuse identity plus a request commitment.
372
- """
373
- now = time.monotonic()
374
- async with self._rate_lock:
375
- self._prune_rate_locked(now)
376
-
377
- retry_key = (identity, request_hash)
378
- retry = self._rate_retries.get(retry_key)
379
- if retry is not None:
380
- retry_count, retry_start = retry
381
- if retry_count >= _MAX_SAME_REQUEST_RETRIES:
382
- return False
383
- self._rate_retries[retry_key] = (retry_count + 1, retry_start)
384
- return True
385
-
386
- if (
387
- identity not in self._rate and len(self._rate) >= _MAX_RATE_IDENTITIES
388
- ) or len(self._rate_retries) >= _MAX_RETRY_KEYS:
389
- self._prune_rate_locked(now, force=True)
390
- if identity not in self._rate and len(self._rate) >= _MAX_RATE_IDENTITIES:
391
- return False
392
- if len(self._rate_retries) >= _MAX_RETRY_KEYS:
393
- # Fail closed rather than allowing attacker-controlled commitment
394
- # cardinality to grow process memory without bound.
395
- return False
396
-
397
- count, start = self._rate.get(identity, (0, now))
398
- if now - start >= _WINDOW_SECONDS:
399
- count, start = 0, now
400
- if count >= self.config.rate_limit_per_hour:
401
- return False
402
- self._rate[identity] = (count + 1, start)
403
- self._rate_retries[retry_key] = (0, now)
404
- return True
405
-
406
- async def _send_json(
407
- self,
408
- send,
409
- status: int,
410
- payload: dict[str, Any],
411
- *,
412
- cors_origin: str = "",
413
- extra_headers: Sequence[tuple[bytes, bytes]] = (),
414
- ) -> None:
415
- body = (
416
- b""
417
- if status == 204 # ruff: ignore[magic-value-comparison]
418
- else _json_bytes(payload)
419
- )
420
- headers = [
421
- (b"cache-control", b"no-store"),
422
- (b"content-length", str(len(body)).encode("ascii")),
423
- (b"x-content-type-options", b"nosniff"),
424
- ]
425
- if status != 204: # ruff: ignore[magic-value-comparison]
426
- headers.insert(0, (b"content-type", b"application/json; charset=utf-8"))
427
- if cors_origin and cors_origin != "!":
428
- headers.extend(
429
- [
430
- (b"access-control-allow-origin", cors_origin.encode("ascii")),
431
- (b"vary", b"Origin"),
432
- ]
433
- )
434
- headers.extend(extra_headers)
435
- try:
436
- await send(
437
- {"type": "http.response.start", "status": status, "headers": headers}
438
- )
439
- await send({"type": "http.response.body", "body": body})
440
- except OSError:
441
- # ASGI servers are allowed to raise OSError when the client disconnects
442
- # after a durable write. The browser retry uses the same event id, so
443
- # suppressing this transport-only broken pipe preserves exact replay.
444
- return
445
-
446
- async def _read_body(self, receive) -> bytes:
447
- body = bytearray()
448
- messages = 0
449
- while True:
450
- message = await receive()
451
- messages += 1
452
- if messages > _MAX_REQUEST_FRAMES:
453
- raise OverflowError("request is too fragmented")
454
- if message.get("type") == "http.disconnect":
455
- raise ConnectionError("client disconnected")
456
- if message.get("type") != "http.request":
457
- continue
458
- raw_chunk = message.get("body", b"")
459
- if not isinstance(raw_chunk, bytes):
460
- raise ValueError( # ruff: ignore[type-check-without-type-error]
461
- "request body frame must contain bytes",
462
- )
463
- if len(body) + len(raw_chunk) > self.config.max_body_bytes:
464
- raise OverflowError("request too large")
465
- body.extend(raw_chunk)
466
- if not message.get("more_body", False):
467
- return bytes(body)
468
-
469
- async def __call__( # ruff: ignore[too-many-branches, too-many-return-statements]
470
- self,
471
- scope,
472
- receive,
473
- send,
474
- ) -> None:
475
- if scope.get("type") != "http":
476
- return
477
- path = str(scope.get("path") or "")
478
- method = str(scope.get("method") or "GET").upper()
479
- try:
480
- # These headers are security-relevant singletons. Ambiguous duplicates
481
- # are rejected instead of relying on first/last-value proxy behavior.
482
- _singleton_header(scope, "content-type")
483
- declared_length = _singleton_header(scope, "content-length")
484
- cors_origin = self._cors_origin(scope)
485
- except FeedbackASGIConfigError:
486
- await self._send_json(send, 400, {"detail": "Ambiguous request headers."})
487
- return
488
- if cors_origin == "!":
489
- await self._send_json(
490
- send, 403, {"detail": "Origin is not allowed for page feedback."}
491
- )
492
- return
493
- if path != _ENDPOINT:
494
- await self._send_json(
495
- send,
496
- 404,
497
- {"detail": "Not found."},
498
- cors_origin=cors_origin,
499
- )
500
- return
501
- if method == "OPTIONS":
502
- if not cors_origin:
503
- await self._send_json(
504
- send,
505
- 204,
506
- {},
507
- cors_origin="",
508
- )
509
- return
510
- await self._send_json(
511
- send,
512
- 204,
513
- {},
514
- cors_origin=cors_origin,
515
- extra_headers=(
516
- (b"access-control-allow-methods", b"POST, OPTIONS"),
517
- (b"access-control-allow-headers", b"Content-Type, Accept"),
518
- (b"access-control-max-age", b"600"),
519
- ),
520
- )
521
- return
522
- if method != "POST":
523
- await self._send_json(
524
- send, 405, {"detail": "Method not allowed."}, cors_origin=cors_origin
525
- )
526
- return
527
- declared_bytes: int | None = None
528
- if declared_length:
529
- if not declared_length.isascii() or not declared_length.isdecimal():
530
- await self._send_json(
531
- send,
532
- 400,
533
- {"detail": "Invalid Content-Length."},
534
- cors_origin=cors_origin,
535
- )
536
- return
537
- try:
538
- declared_bytes = int(declared_length, 10)
539
- except ValueError:
540
- await self._send_json(
541
- send,
542
- 400,
543
- {"detail": "Invalid Content-Length."},
544
- cors_origin=cors_origin,
545
- )
546
- return
547
- if declared_bytes < 0:
548
- await self._send_json(
549
- send,
550
- 400,
551
- {"detail": "Invalid Content-Length."},
552
- cors_origin=cors_origin,
553
- )
554
- return
555
- if declared_bytes > self.config.max_body_bytes:
556
- await self._send_json(
557
- send,
558
- 413,
559
- {
560
- "detail": (
561
- "Page feedback request exceeds the configured safety limit."
562
- ),
563
- },
564
- cors_origin=cors_origin,
565
- )
566
- return
567
- media_type = (
568
- _singleton_header(
569
- scope,
570
- "content-type",
571
- )
572
- .split(";", 1)[0]
573
- .strip()
574
- .lower()
575
- )
576
- if media_type != "application/json":
577
- await self._send_json(
578
- send,
579
- 415,
580
- {"detail": "Page feedback requires application/json."},
581
- cors_origin=cors_origin,
582
- )
583
- return
584
- try:
585
- raw = await self._read_body(receive)
586
- except OverflowError:
587
- await self._send_json(
588
- send,
589
- 413,
590
- {
591
- "detail": (
592
- "Page feedback request exceeds the configured safety limit."
593
- ),
594
- },
595
- cors_origin=cors_origin,
596
- )
597
- return
598
- except ConnectionError:
599
- return
600
- except ValueError:
601
- await self._send_json(
602
- send,
603
- 400,
604
- {"detail": "Malformed page feedback request framing."},
605
- cors_origin=cors_origin,
606
- )
607
- return
608
- if declared_bytes is not None and declared_bytes != len(raw):
609
- await self._send_json(
610
- send,
611
- 400,
612
- {"detail": "Content-Length does not match the request body."},
613
- cors_origin=cors_origin,
614
- )
615
- return
616
- try:
617
- validated = decode_feedback_request(raw)
618
- validated = self.service.validate_request(validated)
619
- except FeedbackValidationError as exc:
620
- await self._send_json(
621
- send, 422, {"detail": str(exc)}, cors_origin=cors_origin
622
- )
623
- return
624
- request_hash = feedback_request_hash(validated)
625
- if not await self._consume_rate(self._rate_identity(scope), request_hash):
626
- await self._send_json(
627
- send,
628
- 429,
629
- {"detail": "Rate limit exceeded for page feedback submissions."},
630
- cors_origin=cors_origin,
631
- extra_headers=((b"retry-after", b"3600"),),
632
- )
633
- return
634
- try:
635
- receipt = await self.service.submit(validated)
636
- except FeedbackConflictError as exc:
637
- await self._send_json(
638
- send, 409, {"detail": str(exc)}, cors_origin=cors_origin
639
- )
640
- return
641
- except FeedbackValidationError as exc:
642
- await self._send_json(
643
- send, 422, {"detail": str(exc)}, cors_origin=cors_origin
644
- )
645
- return
646
- except FeedbackServiceUnavailable:
647
- await self._send_json(
648
- send,
649
- 503,
650
- {"detail": "Page feedback storage is temporarily unavailable."},
651
- cors_origin=cors_origin,
652
- )
653
- return
654
- await self._send_json(send, 202, receipt, cors_origin=cors_origin)
655
-
656
-
657
- def create_app(
658
- env: Mapping[str, str] | None = None,
659
- *,
660
- allowed_origins: str | Sequence[str] | None = None,
661
- ) -> FeedbackASGIApp:
662
- """Create a standalone ASGI app from explicit/process environment config."""
663
- source = os.environ if env is None else env
664
- config = load_service_config(source)
665
- origins = (
666
- source.get("FEEDBACK_ALLOWED_ORIGINS", "")
667
- if allowed_origins is None
668
- else allowed_origins
669
- )
670
- trusted_proxies = parse_trusted_proxy_cidrs(
671
- source.get("FEEDBACK_TRUSTED_PROXY_CIDRS", "")
672
- )
673
- return FeedbackASGIApp(
674
- config,
675
- allowed_origins=parse_allowed_origins(origins),
676
- service=PageFeedbackService(config, credential_env=source),
677
- trusted_proxy_cidrs=trusted_proxies,
678
- )
679
-
680
-
681
- app = create_app()
682
-
683
- __all__ = [
684
- "FeedbackASGIApp",
685
- "FeedbackASGIConfigError",
686
- "app",
687
- "create_app",
688
- "parse_allowed_origins",
689
- "parse_trusted_proxy_cidrs",
690
- ]