apifacts 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. apifacts/__init__.py +3 -0
  2. apifacts/api/__init__.py +5 -0
  3. apifacts/api/app.py +487 -0
  4. apifacts/api/facts_index.py +286 -0
  5. apifacts/api/health.py +67 -0
  6. apifacts/api/match.py +194 -0
  7. apifacts/api/models.py +149 -0
  8. apifacts/api/normalize.py +249 -0
  9. apifacts/api/ratelimit.py +67 -0
  10. apifacts/api/schema.sql +42 -0
  11. apifacts/api/seed_hosts.py +323 -0
  12. apifacts/api/store.py +110 -0
  13. apifacts/bot/__init__.py +1 -0
  14. apifacts/bot/cli.py +73 -0
  15. apifacts/bot/collect.py +146 -0
  16. apifacts/bot/pr.py +327 -0
  17. apifacts/bot/token.py +96 -0
  18. apifacts/cli.py +96 -0
  19. apifacts/cli_check.py +94 -0
  20. apifacts/client/__init__.py +6 -0
  21. apifacts/client/offline.py +142 -0
  22. apifacts/client/render.py +117 -0
  23. apifacts/client/request.py +251 -0
  24. apifacts/client/settings.py +26 -0
  25. apifacts/client/snapshot/README.md +6 -0
  26. apifacts/client/snapshot/facts/github/anonymous-cache-ignores-api-version.yaml +29 -0
  27. apifacts/client/snapshot/facts/github/anonymous-core-rate-limit.yaml +27 -0
  28. apifacts/client/snapshot/facts/github/anonymous-repo-has-no-permissions.yaml +26 -0
  29. apifacts/client/snapshot/facts/github/anonymous-search-rate-limit.yaml +27 -0
  30. apifacts/client/snapshot/facts/github/api-version-2026-drops-has-downloads.yaml +27 -0
  31. apifacts/client/snapshot/facts/github/default-api-version-end-of-support.yaml +22 -0
  32. apifacts/client/snapshot/facts/github/error-status-is-a-string.yaml +28 -0
  33. apifacts/client/snapshot/facts/github/graphql-anonymous-limit-zero.yaml +27 -0
  34. apifacts/client/snapshot/facts/github/missing-repo-404.yaml +30 -0
  35. apifacts/client/snapshot/facts/github/pagination-in-link-header.yaml +27 -0
  36. apifacts/client/snapshot/facts/github/per-page-silently-capped-at-100.yaml +27 -0
  37. apifacts/client/snapshot/facts/github/private-repo-404-not-403.yaml +26 -0
  38. apifacts/client/snapshot/facts/github/rate-object-closing-down.yaml +22 -0
  39. apifacts/client/snapshot/facts/github/renamed-repo-301.yaml +31 -0
  40. apifacts/client/snapshot/facts/github/repository-object-fingerprint.yaml +26 -0
  41. apifacts/client/snapshot/facts/github/search-first-1000-results.yaml +31 -0
  42. apifacts/client/snapshot/facts/github/search-query-longer-than-256.yaml +31 -0
  43. apifacts/client/snapshot/facts/github/secondary-rate-limit-429.yaml +25 -0
  44. apifacts/client/snapshot/facts/github/secondary-rate-limit.yaml +25 -0
  45. apifacts/client/snapshot/facts/github/trailing-slash-404.yaml +31 -0
  46. apifacts/client/snapshot/facts/github/unsupported-api-version-400.yaml +31 -0
  47. apifacts/client/snapshot/facts/github/unsupported-method-404.yaml +24 -0
  48. apifacts/client/snapshot/facts/github/user-agent-required.yaml +24 -0
  49. apifacts/client/snapshot/facts/google-calendar/calendar-get-rejects-api-keys.yaml +31 -0
  50. apifacts/client/snapshot/facts/google-calendar/calendar-not-found-404.yaml +30 -0
  51. apifacts/client/snapshot/facts/google-calendar/events-page-default-250.yaml +22 -0
  52. apifacts/client/snapshot/facts/google-calendar/invalid-page-token-400.yaml +30 -0
  53. apifacts/client/snapshot/facts/google-calendar/max-results-above-2500-accepted.yaml +29 -0
  54. apifacts/client/snapshot/facts/google-calendar/max-results-zero-refused.yaml +30 -0
  55. apifacts/client/snapshot/facts/google-calendar/missing-api-key-403.yaml +30 -0
  56. apifacts/client/snapshot/facts/google-calendar/order-by-start-time-needs-single-events.yaml +30 -0
  57. apifacts/client/snapshot/facts/google-calendar/public-free-busy-calendar-via-api-key.yaml +28 -0
  58. apifacts/client/snapshot/facts/google-calendar/rate-limit-403-or-429.yaml +24 -0
  59. apifacts/client/snapshot/facts/google-calendar/sync-token-with-time-min-400.yaml +30 -0
  60. apifacts/client/snapshot/facts/google-calendar/time-min-filters-on-end-time.yaml +22 -0
  61. apifacts/client/snapshot/facts/google-calendar/time-min-without-offset-bad-request.yaml +31 -0
  62. apifacts/client/snapshot/facts/google-calendar/unknown-sync-token-410.yaml +30 -0
  63. apifacts/client/snapshot/facts/google-firestore/array-and-map-values-are-wrapped.yaml +28 -0
  64. apifacts/client/snapshot/facts/google-firestore/document-envelope-keys.yaml +28 -0
  65. apifacts/client/snapshot/facts/google-firestore/document-not-found-404.yaml +31 -0
  66. apifacts/client/snapshot/facts/google-firestore/document-size-limit-1mib.yaml +22 -0
  67. apifacts/client/snapshot/facts/google-firestore/empty-collection-returns-empty-object.yaml +28 -0
  68. apifacts/client/snapshot/facts/google-firestore/free-quota-resets-midnight-pacific.yaml +22 -0
  69. apifacts/client/snapshot/facts/google-firestore/integer-value-is-a-string.yaml +29 -0
  70. apifacts/client/snapshot/facts/google-firestore/invalid-field-path-400.yaml +31 -0
  71. apifacts/client/snapshot/facts/google-firestore/invalid-page-token-400.yaml +31 -0
  72. apifacts/client/snapshot/facts/google-firestore/negative-page-size-400.yaml +31 -0
  73. apifacts/client/snapshot/facts/google-firestore/null-value-is-json-null.yaml +28 -0
  74. apifacts/client/snapshot/facts/google-firestore/order-by-unknown-field-accepted.yaml +28 -0
  75. apifacts/client/snapshot/facts/google-firestore/server-access-bypasses-security-rules.yaml +22 -0
  76. apifacts/client/snapshot/facts/google-firestore/subcollection-of-missing-document-is-empty.yaml +28 -0
  77. apifacts/client/snapshot/facts/google-firestore/timestamp-value-rfc3339-utc.yaml +28 -0
  78. apifacts/client/snapshot/facts/google-firestore/unauthenticated-read-under-test-mode.yaml +31 -0
  79. apifacts/client/snapshot/facts/google-firestore/unknown-database-404.yaml +31 -0
  80. apifacts/client/snapshot/facts/google-firestore/unknown-project-403.yaml +31 -0
  81. apifacts/client/snapshot/facts/google-gemini/daily-quota-resets-midnight-pacific.yaml +22 -0
  82. apifacts/client/snapshot/facts/google-gemini/errors-use-google-rpc-envelope.yaml +28 -0
  83. apifacts/client/snapshot/facts/google-gemini/gemini-1-5-models-retired.yaml +31 -0
  84. apifacts/client/snapshot/facts/google-gemini/gemini-2-0-flash-served-past-shutdown-date.yaml +29 -0
  85. apifacts/client/snapshot/facts/google-gemini/invalid-api-key-400.yaml +31 -0
  86. apifacts/client/snapshot/facts/google-gemini/invalid-page-token-400.yaml +31 -0
  87. apifacts/client/snapshot/facts/google-gemini/latest-aliases-v1beta-only.yaml +31 -0
  88. apifacts/client/snapshot/facts/google-gemini/leaked-key-403.yaml +27 -0
  89. apifacts/client/snapshot/facts/google-gemini/missing-api-key-403.yaml +31 -0
  90. apifacts/client/snapshot/facts/google-gemini/model-not-found-404.yaml +31 -0
  91. apifacts/client/snapshot/facts/google-gemini/model-object-fingerprint.yaml +28 -0
  92. apifacts/client/snapshot/facts/google-gemini/models-list-default-page-50.yaml +28 -0
  93. apifacts/client/snapshot/facts/google-gemini/page-size-negative-400.yaml +31 -0
  94. apifacts/client/snapshot/facts/google-gemini/page-size-zero-means-default.yaml +28 -0
  95. apifacts/client/snapshot/facts/google-gemini/rate-limits-per-project.yaml +22 -0
  96. apifacts/client/snapshot/facts/google-gemini/resource-exhausted-429.yaml +25 -0
  97. apifacts/client/snapshot/facts/google-gemini/unknown-query-parameter-400.yaml +31 -0
  98. apifacts/client/snapshot/facts/google-sheets/batch-get-fails-whole.yaml +31 -0
  99. apifacts/client/snapshot/facts/google-sheets/columns-major-inner-empty-cells.yaml +28 -0
  100. apifacts/client/snapshot/facts/google-sheets/date-time-formatted-string.yaml +28 -0
  101. apifacts/client/snapshot/facts/google-sheets/default-sheet-name-follows-locale.yaml +31 -0
  102. apifacts/client/snapshot/facts/google-sheets/empty-range-omits-values.yaml +28 -0
  103. apifacts/client/snapshot/facts/google-sheets/formatted-values-follow-locale.yaml +28 -0
  104. apifacts/client/snapshot/facts/google-sheets/formulas-in-locale-syntax.yaml +28 -0
  105. apifacts/client/snapshot/facts/google-sheets/invalid-api-key-400.yaml +31 -0
  106. apifacts/client/snapshot/facts/google-sheets/invalid-fields-mask-generic-400.yaml +31 -0
  107. apifacts/client/snapshot/facts/google-sheets/invalid-value-render-option-400.yaml +31 -0
  108. apifacts/client/snapshot/facts/google-sheets/missing-api-key-403.yaml +31 -0
  109. apifacts/client/snapshot/facts/google-sheets/new-sheet-grid-1000x26.yaml +28 -0
  110. apifacts/client/snapshot/facts/google-sheets/quota-per-project-and-user.yaml +24 -0
  111. apifacts/client/snapshot/facts/google-sheets/range-clipped-to-grid.yaml +28 -0
  112. apifacts/client/snapshot/facts/google-sheets/request-timeout-180s.yaml +22 -0
  113. apifacts/client/snapshot/facts/google-sheets/spreadsheet-not-found-404.yaml +31 -0
  114. apifacts/client/snapshot/facts/google-sheets/unformatted-dates-are-serial-numbers.yaml +28 -0
  115. apifacts/client/snapshot/facts/google-sheets/unknown-sheet-unable-to-parse.yaml +31 -0
  116. apifacts/client/snapshot/facts/google-sheets/value-rows-not-padded.yaml +28 -0
  117. apifacts/client/snapshot/facts/google-sheets/whole-column-range-expanded.yaml +28 -0
  118. apifacts/client/snapshot/facts/pypi/downloads-always-minus-one.yaml +27 -0
  119. apifacts/client/snapshot/facts/pypi/json-api-accepts-unnormalized-name.yaml +26 -0
  120. apifacts/client/snapshot/facts/pypi/json-api-cdn-cache.yaml +27 -0
  121. apifacts/client/snapshot/facts/pypi/json-trailing-slash-301.yaml +28 -0
  122. apifacts/client/snapshot/facts/pypi/no-edge-rate-limit.yaml +22 -0
  123. apifacts/client/snapshot/facts/pypi/project-json-fingerprint.yaml +26 -0
  124. apifacts/client/snapshot/facts/pypi/project-not-found-404.yaml +30 -0
  125. apifacts/client/snapshot/facts/pypi/release-json-has-no-releases-key.yaml +27 -0
  126. apifacts/client/snapshot/facts/pypi/release-metadata-from-first-upload.yaml +22 -0
  127. apifacts/client/snapshot/facts/pypi/release-not-found-404.yaml +30 -0
  128. apifacts/client/snapshot/facts/pypi/releases-key-deprecated.yaml +22 -0
  129. apifacts/client/snapshot/facts/pypi/simple-api-html-unless-accept-json.yaml +27 -0
  130. apifacts/client/snapshot/facts/pypi/simple-not-found-404.yaml +30 -0
  131. apifacts/client/snapshot/facts/pypi/simple-unnormalized-name-301.yaml +29 -0
  132. apifacts/client/snapshot/facts/pypi/simple-without-trailing-slash-301.yaml +28 -0
  133. apifacts/client/snapshot/facts/pypi/xml-rpc-planned-deprecation.yaml +22 -0
  134. apifacts/client/snapshot/facts/salesforce/developer-edition-15000-daily-requests.yaml +28 -0
  135. apifacts/client/snapshot/facts/salesforce/empty-query-result-shape.yaml +28 -0
  136. apifacts/client/snapshot/facts/salesforce/errors-are-a-json-array.yaml +28 -0
  137. apifacts/client/snapshot/facts/salesforce/missing-session-401.yaml +31 -0
  138. apifacts/client/snapshot/facts/salesforce/non-id-path-read-as-external-id.yaml +31 -0
  139. apifacts/client/snapshot/facts/salesforce/record-not-found-404.yaml +31 -0
  140. apifacts/client/snapshot/facts/salesforce/rest-on-login-host-url-not-reset.yaml +31 -0
  141. apifacts/client/snapshot/facts/salesforce/retired-api-version-410.yaml +30 -0
  142. apifacts/client/snapshot/facts/salesforce/sforce-limit-info-header.yaml +28 -0
  143. apifacts/client/snapshot/facts/salesforce/soap-login-needs-use-any-api-auth.yaml +24 -0
  144. apifacts/client/snapshot/facts/salesforce/soap-login-rate-3600-per-hour.yaml +22 -0
  145. apifacts/client/snapshot/facts/salesforce/soap-login-retiring-summer-27.yaml +22 -0
  146. apifacts/client/snapshot/facts/salesforce/soql-fields-all-needs-limit.yaml +31 -0
  147. apifacts/client/snapshot/facts/salesforce/soql-malformed-query-400.yaml +31 -0
  148. apifacts/client/snapshot/facts/salesforce/soql-unknown-field-invalid-field.yaml +31 -0
  149. apifacts/client/snapshot/facts/salesforce/soql-unknown-object-invalid-type.yaml +31 -0
  150. apifacts/client/snapshot/facts/salesforce/unknown-sobject-describe-404.yaml +31 -0
  151. apifacts/client/snapshot/facts/salesforce/version-list-needs-no-session.yaml +28 -0
  152. apifacts/client/snapshot/facts/solana/get-block-commitment-processed.yaml +31 -0
  153. apifacts/client/snapshot/facts/solana/get-confirmed-block-removed.yaml +32 -0
  154. apifacts/client/snapshot/facts/solana/get-confirmed-transaction-removed.yaml +32 -0
  155. apifacts/client/snapshot/facts/solana/get-program-accounts-blocked-403.yaml +31 -0
  156. apifacts/client/snapshot/facts/solana/get-recent-blockhash-removed.yaml +32 -0
  157. apifacts/client/snapshot/facts/solana/invalid-pubkey-wrong-size.yaml +31 -0
  158. apifacts/client/snapshot/facts/solana/jsonrpc-errors-use-http-200.yaml +28 -0
  159. apifacts/client/snapshot/facts/solana/multiple-accounts-max-100.yaml +31 -0
  160. apifacts/client/snapshot/facts/solana/per-method-rate-limit-headers.yaml +26 -0
  161. apifacts/client/snapshot/facts/solana/public-endpoint-403-blocked.yaml +24 -0
  162. apifacts/client/snapshot/facts/solana/public-endpoint-not-for-production.yaml +22 -0
  163. apifacts/client/snapshot/facts/solana/public-mainnet-rate-limits.yaml +24 -0
  164. apifacts/client/snapshot/facts/solana/rent-exemption-minimum-zero-bytes.yaml +26 -0
  165. apifacts/client/snapshot/facts/solana/signatures-for-address-limit-1000.yaml +31 -0
  166. apifacts/client/snapshot/facts/stripe/amount-too-small.yaml +31 -0
  167. apifacts/client/snapshot/facts/stripe/card-declined-generic.yaml +31 -0
  168. apifacts/client/snapshot/facts/stripe/card-expired.yaml +31 -0
  169. apifacts/client/snapshot/facts/stripe/card-incorrect-cvc.yaml +31 -0
  170. apifacts/client/snapshot/facts/stripe/card-insufficient-funds.yaml +31 -0
  171. apifacts/client/snapshot/facts/stripe/card-lost-generic-message.yaml +31 -0
  172. apifacts/client/snapshot/facts/stripe/customer-not-found.yaml +31 -0
  173. apifacts/client/snapshot/facts/stripe/deleted-customer-returns-200.yaml +28 -0
  174. apifacts/client/snapshot/facts/stripe/execution-errors-are-replayed.yaml +31 -0
  175. apifacts/client/snapshot/facts/stripe/expand-not-expandable.yaml +30 -0
  176. apifacts/client/snapshot/facts/stripe/idempotency-key-other-endpoint.yaml +31 -0
  177. apifacts/client/snapshot/facts/stripe/idempotency-key-reuse-different-params.yaml +31 -0
  178. apifacts/client/snapshot/facts/stripe/idempotency-key-too-long.yaml +31 -0
  179. apifacts/client/snapshot/facts/stripe/idempotency-keys-pruned-after-24h.yaml +22 -0
  180. apifacts/client/snapshot/facts/stripe/idempotent-replay-header.yaml +28 -0
  181. apifacts/client/snapshot/facts/stripe/invalid-api-version-400.yaml +30 -0
  182. apifacts/client/snapshot/facts/stripe/limit-not-an-integer.yaml +31 -0
  183. apifacts/client/snapshot/facts/stripe/limit-zero-returns-one.yaml +28 -0
  184. apifacts/client/snapshot/facts/stripe/missing-api-key-401.yaml +30 -0
  185. apifacts/client/snapshot/facts/stripe/parameter-unknown.yaml +31 -0
  186. apifacts/client/snapshot/facts/stripe/payment-method-types-removed.yaml +30 -0
  187. apifacts/client/snapshot/facts/stripe/rate-limited-reason-header.yaml +24 -0
  188. apifacts/client/snapshot/facts/stripe/sandbox-rate-limit-lower.yaml +22 -0
  189. apifacts/client/snapshot/facts/stripe/server-assigned-idempotency-key.yaml +28 -0
  190. apifacts/client/snapshot/facts/stripe/starting-after-missing-object.yaml +31 -0
  191. apifacts/client/snapshot/facts/stripe/validation-errors-are-not-replayed.yaml +31 -0
  192. apifacts/client/snapshot/facts/telegram/chat-id-empty-400.yaml +30 -0
  193. apifacts/client/snapshot/facts/telegram/chat-not-found-400.yaml +30 -0
  194. apifacts/client/snapshot/facts/telegram/error-envelope-ok-false.yaml +29 -0
  195. apifacts/client/snapshot/facts/telegram/flood-control-429-retry-after.yaml +24 -0
  196. apifacts/client/snapshot/facts/telegram/get-updates-blocked-by-webhook.yaml +22 -0
  197. apifacts/client/snapshot/facts/telegram/get-updates-limit-not-validated.yaml +29 -0
  198. apifacts/client/snapshot/facts/telegram/group-migrated-to-supergroup.yaml +22 -0
  199. apifacts/client/snapshot/facts/telegram/invalid-file-id-400.yaml +30 -0
  200. apifacts/client/snapshot/facts/telegram/invalid-token-401.yaml +30 -0
  201. apifacts/client/snapshot/facts/telegram/malformed-token-404.yaml +30 -0
  202. apifacts/client/snapshot/facts/telegram/message-rate-limits.yaml +24 -0
  203. apifacts/client/snapshot/facts/telegram/method-names-case-insensitive.yaml +29 -0
  204. apifacts/client/snapshot/facts/telegram/no-webhook-url-empty-string.yaml +28 -0
  205. apifacts/client/snapshot/facts/telegram/unknown-method-404.yaml +30 -0
  206. apifacts/client/snapshot/facts/telegram/updates-kept-24-hours.yaml +22 -0
  207. apifacts/client/snapshot/facts/telegram/user-not-found-400.yaml +30 -0
  208. apifacts/client/snapshot/services/github.yaml +44 -0
  209. apifacts/client/snapshot/services/google-calendar.yaml +34 -0
  210. apifacts/client/snapshot/services/google-firestore.yaml +39 -0
  211. apifacts/client/snapshot/services/google-gemini.yaml +35 -0
  212. apifacts/client/snapshot/services/google-sheets.yaml +33 -0
  213. apifacts/client/snapshot/services/pypi.yaml +34 -0
  214. apifacts/client/snapshot/services/salesforce.yaml +38 -0
  215. apifacts/client/snapshot/services/solana.yaml +44 -0
  216. apifacts/client/snapshot/services/stripe.yaml +41 -0
  217. apifacts/client/snapshot/services/telegram.yaml +39 -0
  218. apifacts/client/transport.py +235 -0
  219. apifacts/fixes/__init__.py +1 -0
  220. apifacts/fixes/templates.py +84 -0
  221. apifacts/pages/__init__.py +5 -0
  222. apifacts/pages/build.py +215 -0
  223. apifacts/pages/cli.py +74 -0
  224. apifacts/pages/errors_index.py +42 -0
  225. apifacts/pages/templates.py +364 -0
  226. apifacts/probe/__init__.py +12 -0
  227. apifacts/probe/assertions.py +170 -0
  228. apifacts/probe/budget.py +131 -0
  229. apifacts/probe/cli.py +145 -0
  230. apifacts/probe/evidence.py +280 -0
  231. apifacts/probe/google_sa.py +148 -0
  232. apifacts/probe/http.py +281 -0
  233. apifacts/probe/keys.py +43 -0
  234. apifacts/probe/ledger.py +90 -0
  235. apifacts/probe/loader.py +93 -0
  236. apifacts/probe/oauth_refresh.py +127 -0
  237. apifacts/probe/redact.py +123 -0
  238. apifacts/probe/results.py +147 -0
  239. apifacts/probe/runner.py +700 -0
  240. apifacts/probe/templating.py +138 -0
  241. apifacts/quorum/__init__.py +2 -0
  242. apifacts/quorum/cli.py +87 -0
  243. apifacts/quorum/draft.py +163 -0
  244. apifacts/quorum/group.py +123 -0
  245. apifacts/quorum/rules.py +82 -0
  246. apifacts/quorum/run.py +132 -0
  247. apifacts/schema/__init__.py +39 -0
  248. apifacts/schema/error_paths.py +44 -0
  249. apifacts/schema/json/fact.schema.json +182 -0
  250. apifacts/schema/json/fix.schema.json +244 -0
  251. apifacts/schema/json/observation.schema.json +25 -0
  252. apifacts/schema/json/probe.schema.json +181 -0
  253. apifacts/schema/json/service.schema.json +215 -0
  254. apifacts/schema/models.py +702 -0
  255. apifacts/schema/registry.py +238 -0
  256. apifacts/schema/substance.py +24 -0
  257. apifacts/schema/validate.py +433 -0
  258. apifacts/schema/yamlio.py +70 -0
  259. apifacts-0.1.0.dist-info/METADATA +94 -0
  260. apifacts-0.1.0.dist-info/RECORD +264 -0
  261. apifacts-0.1.0.dist-info/WHEEL +4 -0
  262. apifacts-0.1.0.dist-info/entry_points.txt +2 -0
  263. apifacts-0.1.0.dist-info/licenses/LICENSE +21 -0
  264. apifacts-0.1.0.dist-info/licenses/LICENSE-DATA +9 -0
apifacts/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """apifacts: verified facts about machine interfaces for AI agents."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """`check()` service: POST /v0/check (request = contribution), read endpoints, ingestion.
2
+
3
+ Spec: docs/specs/03-check-contract.md. Nothing here calls a model API or the network; the answer
4
+ comes from the facts in git, the observation is a normalized row in sqlite or nothing.
5
+ """
apifacts/api/app.py ADDED
@@ -0,0 +1,487 @@
1
+ """FastAPI application: `uvicorn apifacts.api.app:app` (spec 03).
2
+
3
+ Configuration (environment, read at startup): `APIFACTS_REPO` (registry checkout, default `.`),
4
+ `APIFACTS_DB` (sqlite file, default `observations.sqlite`), `APIFACTS_TRUSTED_PROXIES`
5
+ (comma-separated IPs/CIDRs whose `X-Forwarded-For` is believed, default `127.0.0.1,::1`),
6
+ `APIFACTS_REGISTRY_VERSION` (overrides the version derived from the facts), `APIFACTS_RESULTS`
7
+ (the probe runner's results.jsonl for `/healthz`, default `<repo>/var/results.jsonl`).
8
+
9
+ The facts index is read once, at startup: a new registry needs a restart (`ops/deploy.sh` does it).
10
+
11
+ One process: rate limits live in memory and the store is one sqlite connection.
12
+ """
13
+
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import secrets
18
+ from collections.abc import Callable
19
+ from contextlib import asynccontextmanager
20
+ from dataclasses import dataclass
21
+ from datetime import UTC, datetime
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from fastapi import FastAPI, HTTPException, Request
26
+ from fastapi.exceptions import RequestValidationError
27
+ from fastapi.responses import JSONResponse, Response
28
+ from starlette.datastructures import Headers
29
+ from starlette.exceptions import HTTPException as StarletteHTTPException
30
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
31
+
32
+ from apifacts.api.facts_index import (
33
+ FactsIndex,
34
+ IndexedFact,
35
+ ServiceEntry,
36
+ Template,
37
+ instance_hosts_of,
38
+ )
39
+ from apifacts.api.health import last_probe_run
40
+ from apifacts.api.match import (
41
+ HostTarget,
42
+ Locator,
43
+ Network,
44
+ Target,
45
+ UrlTarget,
46
+ best_template,
47
+ classify_ua,
48
+ client_ip,
49
+ network_of,
50
+ parse_proxies,
51
+ parse_target,
52
+ )
53
+ from apifacts.api.models import (
54
+ CheckRequest,
55
+ CheckResponse,
56
+ ErrorResponse,
57
+ FactOut,
58
+ FactsResponse,
59
+ Health,
60
+ ServicesResponse,
61
+ )
62
+ from apifacts.api.normalize import ExtractedSignal, extract_signal, find_urls, looks_like_prompt
63
+ from apifacts.api.ratelimit import RateLimiter
64
+ from apifacts.api.seed_hosts import metric_host
65
+ from apifacts.api.store import Store, salt_period
66
+ from apifacts.schema.registry import on_vendor_domain
67
+
68
+ LICENSE = "CC-BY-4.0"
69
+ MAX_BODY = 8192 # spec 03: max request body
70
+ DAILY_CAP = 1000 # spec 05: observations per reporter net per day
71
+ CACHE_CONTROL = "public, max-age=300"
72
+ DEFAULT_TRUSTED_PROXIES = ("127.0.0.1", "::1")
73
+ CHECK_PATH = "/v0/check"
74
+
75
+
76
+ def _error_body(code: str, message: str, field: str | None = None) -> dict[str, Any]:
77
+ return {"error": {"code": code, "message": message, "field": field}}
78
+
79
+
80
+ def _error(status: int, code: str, message: str, field: str | None = None, **kw) -> JSONResponse:
81
+ return JSONResponse(_error_body(code, message, field), status_code=status, **kw)
82
+
83
+
84
+ def _fail(status: int, code: str, message: str, field: str | None = None) -> HTTPException:
85
+ return HTTPException(status, detail=_error_body(code, message, field)["error"])
86
+
87
+
88
+ # ── state ───────────────────────────────────────────────────────────────────────────────────────
89
+
90
+
91
+ @dataclass
92
+ class _State:
93
+ index: FactsIndex
94
+ store: Store
95
+ daily_cap: int
96
+ trusted_proxies: tuple[Network, ...]
97
+ utcnow: Callable[[], datetime]
98
+ results: Path
99
+
100
+
101
+ def _state(request: Request) -> _State:
102
+ return request.app.state.apifacts
103
+
104
+
105
+ def _caller_ip(scope_client: Any, headers: Headers, trusted: tuple[Network, ...]) -> str:
106
+ peer = scope_client[0] if scope_client else ""
107
+ return client_ip(peer, headers.get("x-forwarded-for"), trusted)
108
+
109
+
110
+ class _CheckGuard:
111
+ """Before FastAPI reads the body of POST /v0/check: rate limit (429), then size (413), so
112
+ neither a flood nor an oversized body costs a parse."""
113
+
114
+ def __init__(self, app: ASGIApp, limiter: RateLimiter, trusted: tuple[Network, ...]):
115
+ self.app, self.limiter, self.trusted = app, limiter, trusted
116
+ self._key = secrets.token_bytes(16) # limiter keys: never a raw network, even in memory
117
+
118
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
119
+ if scope["type"] != "http" or scope["path"] != CHECK_PATH or scope["method"] != "POST":
120
+ await self.app(scope, receive, send)
121
+ return
122
+ headers = Headers(scope=scope)
123
+ net = network_of(_caller_ip(scope.get("client"), headers, self.trusted))
124
+ key = hashlib.blake2b(net.encode(), key=self._key, digest_size=16).hexdigest()
125
+ if (retry := self.limiter.hit(key)) is not None:
126
+ response = _error(
127
+ 429, "rate_limited", "too many requests", headers={"Retry-After": str(retry)}
128
+ )
129
+ await response(scope, receive, send)
130
+ return
131
+ length = headers.get("content-length")
132
+ if length is not None and not length.isdigit():
133
+ await _error(400, "invalid_request", "malformed Content-Length")(scope, receive, send)
134
+ return
135
+ if length is not None and int(length) > MAX_BODY:
136
+ await _error(413, "body_too_large", f"body over {MAX_BODY} bytes")(scope, receive, send)
137
+ return
138
+ received = 0
139
+
140
+ async def limited() -> Message:
141
+ nonlocal received
142
+ message = await receive()
143
+ if message["type"] == "http.request":
144
+ received += len(message.get("body", b""))
145
+ if received > MAX_BODY: # chunked body: FastAPI re-raises HTTPException as is
146
+ raise _fail(413, "body_too_large", f"body over {MAX_BODY} bytes")
147
+ return message
148
+
149
+ await self.app(scope, limited, send)
150
+
151
+
152
+ # ── POST /v0/check ──────────────────────────────────────────────────────────────────────────────
153
+
154
+
155
+ def _resolve(
156
+ index: FactsIndex, target: Target | None, method: str | None
157
+ ) -> tuple[ServiceEntry | None, Template | None]:
158
+ """Spec 03 step 1: host → services (exact `hosts`, or `auth.instance_hosts` and its
159
+ subdomains, D-090), path → template (`{id}` = one segment)."""
160
+ if isinstance(target, UrlTarget):
161
+ candidates = index.services_for_host(target.host)
162
+ for entry in candidates:
163
+ if (template := best_template(entry.info.templates, method, target.path)) is not None:
164
+ return entry, template
165
+ return (candidates[0] if len(candidates) == 1 else None), None
166
+ if isinstance(target, HostTarget):
167
+ candidates = index.services_for_host(target.host)
168
+ elif isinstance(target, Locator):
169
+ candidates = index.services_for_package(target.ecosystem, target.package)
170
+ else:
171
+ candidates = []
172
+ return (candidates[0] if len(candidates) == 1 else None), None
173
+
174
+
175
+ def _drop_reason(entry: ServiceEntry, signal: ExtractedSignal | None) -> str | None:
176
+ """Spec 05: a string with a URL off the vendor's hosts, or shaped like a prompt."""
177
+ raw = signal.raw_string if signal else None
178
+ if not raw:
179
+ return None
180
+ if any(not on_vendor_domain(url, entry.info.vendor_hosts) for url in find_urls(raw)):
181
+ return "dropped_foreign_url"
182
+ if looks_like_prompt(raw):
183
+ return "dropped_prompt"
184
+ return None
185
+
186
+
187
+ def _ingest(
188
+ state: _State,
189
+ entry: ServiceEntry,
190
+ template: Template,
191
+ signal: ExtractedSignal,
192
+ req: CheckRequest,
193
+ target: UrlTarget,
194
+ ip: str,
195
+ user_agent: str | None,
196
+ now: datetime,
197
+ ) -> bool:
198
+ """Spec 03 step 4: one normalized row, or nothing (dropped and counted). The reporter salt
199
+ is the week's (Q6): one network keeps one id for the whole quorum window."""
200
+ day = now.date().isoformat()
201
+ if (dropped := _drop_reason(entry, signal)) is not None:
202
+ state.store.bump(dropped, day)
203
+ return False
204
+ salt = state.store.period_salt(salt_period(now.date()).isoformat())
205
+ context = req.context
206
+ sdk = context.sdk if context else None
207
+ row = {
208
+ "received_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
209
+ "service": entry.key,
210
+ "method": template[0],
211
+ "path_template": template[1],
212
+ "status": signal.status,
213
+ "error_hash": signal.error_hash,
214
+ "normalized_error": signal.normalized,
215
+ "reporter_net": hashlib.sha256(salt + network_of(ip).encode()).hexdigest(),
216
+ "reporter_ua_class": classify_ua(user_agent),
217
+ "sdk_package": sdk.package if sdk else None,
218
+ "sdk_version": sdk.version if sdk else None,
219
+ "env_guess": (context.env_hint if context else None) or entry.env_for_host(target.host),
220
+ "vantage_country": None,
221
+ }
222
+ if state.store.add_observation(row, daily_cap=state.daily_cap) is None:
223
+ state.store.bump("dropped_daily_cap", day)
224
+ return False
225
+ state.store.bump("stored", day)
226
+ return True
227
+
228
+
229
+ def _count_unregistered(store: Store, target: Target | None, day: str) -> None:
230
+ """Spec 03 step 1 (Q7): one anonymous daily count, labelled with the host only when it is a
231
+ seed candidate's API host; every other target (package, unparsed, unknown host) is `other`.
232
+ The unlabelled total is kept as well, so a reader can sum one name."""
233
+ host = target.host if isinstance(target, UrlTarget | HostTarget) else None
234
+ store.bump("unregistered_target", day)
235
+ store.bump(f"unregistered_target:{metric_host(host)}", day)
236
+
237
+
238
+ def _observed(entry: ServiceEntry | None) -> dict[str, Any]:
239
+ """What the probes know about the target. Probe results are not wired in yet (W2/W4):
240
+ the structure is always there, with an empty distribution and nulls."""
241
+ return {
242
+ "status_distribution_30d": {},
243
+ "p50_latency_ms": None,
244
+ "last_probe": None,
245
+ "rate_limit_headers": list(entry.rate_limit_headers) if entry else [],
246
+ }
247
+
248
+
249
+ def _registry(index: FactsIndex, entry: ServiceEntry | None) -> dict[str, Any]:
250
+ return {
251
+ "version": index.version,
252
+ "facts_for_service": len(entry.facts) if entry else 0,
253
+ "license": LICENSE,
254
+ }
255
+
256
+
257
+ def _check(state: _State, req: CheckRequest, ip: str, user_agent: str | None) -> dict[str, Any]:
258
+ index, now = state.index, state.utcnow()
259
+ target = parse_target(req.target.url)
260
+ method = req.target.method
261
+ if isinstance(target, UrlTarget) and method is None:
262
+ raise _fail(
263
+ 400, "invalid_request", "target.method is required for a URL with a path",
264
+ "target.method",
265
+ )
266
+ sdk_ctx = req.context.sdk if req.context else None
267
+ sdk = Locator(sdk_ctx.ecosystem, sdk_ctx.package, sdk_ctx.version) if sdk_ctx else None
268
+ entry, template = _resolve(index, target, method)
269
+
270
+ facts: list[IndexedFact]
271
+ stored = False
272
+ if isinstance(target, UrlTarget) and entry is not None and template is not None:
273
+ signal = None
274
+ if req.error is not None:
275
+ signal = extract_signal(req.error.status, req.error.body_excerpt, entry.signal_fields)
276
+ if signal is None:
277
+ facts = index.for_template(entry, template, sdk)
278
+ reason = None if facts else "no_fact_for_signal"
279
+ elif signal.empty:
280
+ facts, reason = index.service_level(entry, sdk), "signal_not_extractable"
281
+ else:
282
+ facts, answered = index.for_signal(entry, template, signal, sdk)
283
+ reason = None if answered else "no_fact_for_signal"
284
+ if signal is not None and not signal.empty: # pre-flight and empty errors: never (Q5)
285
+ stored = _ingest(state, entry, template, signal, req, target, ip, user_agent, now)
286
+ elif entry is not None and isinstance(target, HostTarget):
287
+ facts = index.for_service(entry, sdk)
288
+ reason = None if facts else "no_fact_for_signal"
289
+ elif entry is not None and isinstance(target, Locator):
290
+ facts = index.for_package(entry, target)
291
+ reason = None if facts else "no_fact_for_signal"
292
+ else: # default-deny: answer with what the host's service knows, store nothing
293
+ facts = index.service_level(entry, sdk) if entry is not None else []
294
+ entry = entry if isinstance(target, UrlTarget) else None
295
+ reason = "unregistered_target"
296
+ _count_unregistered(state.store, target, now.date().isoformat())
297
+
298
+ return {
299
+ "service": entry.key if entry else None,
300
+ "matched_template": {"method": template[0], "path": template[1]} if template else None,
301
+ "facts": [f.document for f in facts],
302
+ "observed": _observed(entry),
303
+ "unknown": {"reason": reason, "stored_as_observation": stored},
304
+ "registry": _registry(index, entry),
305
+ }
306
+
307
+
308
+ # ── read endpoints ──────────────────────────────────────────────────────────────────────────────
309
+
310
+
311
+ def _cached(request: Request, payload: Any) -> Response:
312
+ """JSON with a strong ETag and `Cache-Control: public, max-age=300` (spec 03)."""
313
+ body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
314
+ etag = f'"{hashlib.sha256(body).hexdigest()[:32]}"'
315
+ headers = {"ETag": etag, "Cache-Control": CACHE_CONTROL}
316
+ wanted = request.headers.get("if-none-match", "")
317
+ tags = {t.strip().removeprefix("W/") for t in wanted.split(",")}
318
+ if etag in tags or "*" in tags:
319
+ return Response(status_code=304, headers=headers)
320
+ return Response(body, media_type="application/json", headers=headers)
321
+
322
+
323
+ # ── errors ──────────────────────────────────────────────────────────────────────────────────────
324
+
325
+
326
+ def _field(loc: tuple[Any, ...]) -> str | None:
327
+ parts = [str(p) for p in loc[1:]] if loc and loc[0] == "body" else [str(p) for p in loc]
328
+ return ".".join(parts) or None
329
+
330
+
331
+ async def _on_validation(request: Request, exc: RequestValidationError) -> JSONResponse:
332
+ errors = exc.errors()
333
+ extra = next((e for e in errors if e.get("type") == "extra_forbidden"), None)
334
+ if extra is not None:
335
+ field = _field(tuple(extra["loc"]))
336
+ return _error(400, "unknown_field", f"unknown field {field}", field)
337
+ first = errors[0] if errors else {}
338
+ message = first.get("msg", "invalid request") # pydantic messages never echo the input
339
+ return _error(400, "invalid_request", message, _field(tuple(first.get("loc", ()))))
340
+
341
+
342
+ _DEFAULT_CODES = {404: "not_found", 405: "method_not_allowed", 413: "body_too_large"}
343
+
344
+
345
+ async def _on_http(request: Request, exc: StarletteHTTPException) -> JSONResponse:
346
+ if isinstance(exc.detail, dict) and "code" in exc.detail:
347
+ body = {"error": exc.detail}
348
+ else:
349
+ code = _DEFAULT_CODES.get(exc.status_code, "error")
350
+ body = _error_body(code, str(exc.detail))
351
+ return JSONResponse(body, status_code=exc.status_code, headers=exc.headers)
352
+
353
+
354
+ def _without_422(default: Callable[[], dict[str, Any]]) -> Callable[[], dict[str, Any]]:
355
+ """Validation errors are answered 400 with the error envelope, never FastAPI's 422."""
356
+
357
+ def openapi() -> dict[str, Any]:
358
+ schema = default()
359
+ for operations in schema.get("paths", {}).values():
360
+ for operation in operations.values():
361
+ operation.get("responses", {}).pop("422", None)
362
+ for name in ("HTTPValidationError", "ValidationError"):
363
+ schema.get("components", {}).get("schemas", {}).pop(name, None)
364
+ return schema
365
+
366
+ return openapi
367
+
368
+
369
+ # ── factory ─────────────────────────────────────────────────────────────────────────────────────
370
+
371
+ _ERRORS = {status: {"model": ErrorResponse} for status in (400, 413, 429)}
372
+ _NOT_FOUND = {404: {"model": ErrorResponse}}
373
+
374
+
375
+ def create_app(
376
+ repo: str | Path | None = None,
377
+ db_path: str | Path | None = None,
378
+ *,
379
+ results_path: str | Path | None = None,
380
+ per_net: int = 60,
381
+ global_limit: int = 600,
382
+ daily_cap: int = DAILY_CAP,
383
+ trusted_proxies: tuple[str, ...] | None = None,
384
+ clock: Callable[[], float] | None = None,
385
+ utcnow: Callable[[], datetime] | None = None,
386
+ ) -> FastAPI:
387
+ """Arguments override the environment (tests); the index and the store open at startup."""
388
+ env_proxies = os.environ.get("APIFACTS_TRUSTED_PROXIES", ",".join(DEFAULT_TRUSTED_PROXIES))
389
+ trusted = parse_proxies(trusted_proxies or env_proxies.split(","))
390
+
391
+ @asynccontextmanager
392
+ async def lifespan(app: FastAPI):
393
+ root = Path(repo if repo is not None else os.environ.get("APIFACTS_REPO", "."))
394
+ store = Store(db_path or os.environ.get("APIFACTS_DB", "observations.sqlite"))
395
+ app.state.apifacts = _State(
396
+ index=FactsIndex.load(root, os.environ.get("APIFACTS_REGISTRY_VERSION") or None),
397
+ store=store,
398
+ daily_cap=daily_cap,
399
+ trusted_proxies=trusted,
400
+ utcnow=utcnow or (lambda: datetime.now(UTC)),
401
+ results=Path(
402
+ results_path
403
+ or os.environ.get("APIFACTS_RESULTS")
404
+ or root / "var" / "results.jsonl"
405
+ ),
406
+ )
407
+ try:
408
+ yield
409
+ finally:
410
+ store.close()
411
+
412
+ app = FastAPI(
413
+ title="apifacts",
414
+ version="v0",
415
+ summary="Verified facts about machine interfaces for AI agents (data CC BY 4.0).",
416
+ lifespan=lifespan,
417
+ )
418
+ limiter_kwargs = {"clock": clock} if clock else {}
419
+ app.add_middleware(
420
+ _CheckGuard,
421
+ limiter=RateLimiter(per_net=per_net, global_limit=global_limit, **limiter_kwargs),
422
+ trusted=trusted,
423
+ )
424
+ app.add_exception_handler(RequestValidationError, _on_validation)
425
+ app.add_exception_handler(StarletteHTTPException, _on_http)
426
+ app.openapi = _without_422(app.openapi)
427
+
428
+ @app.post(CHECK_PATH, response_model=CheckResponse, responses=_ERRORS)
429
+ def check(req: CheckRequest, request: Request) -> Response:
430
+ """Facts and typed fixes for a target and (optionally) an observed error. Never empty.
431
+ A target outside the registered templates is answered but not stored."""
432
+ state = _state(request)
433
+ ip = _caller_ip(request.scope.get("client"), request.headers, state.trusted_proxies)
434
+ return JSONResponse(_check(state, req, ip, request.headers.get("user-agent")))
435
+
436
+ @app.get("/v0/services", response_model=ServicesResponse)
437
+ def services(request: Request) -> Response:
438
+ index = _state(request).index
439
+ payload = {
440
+ "services": [
441
+ {
442
+ "key": e.key,
443
+ "name": e.service.name,
444
+ "hosts": list(e.service.hosts),
445
+ "instance_hosts": list(instance_hosts_of(e.service)),
446
+ "templates": len(e.info.templates),
447
+ "facts": len(e.facts),
448
+ }
449
+ for e in index.services.values()
450
+ ],
451
+ "registry": {"version": index.version, "license": LICENSE},
452
+ }
453
+ return _cached(request, payload)
454
+
455
+ @app.get("/v0/facts/{service}", response_model=FactsResponse, responses=_NOT_FOUND)
456
+ def facts(service: str, request: Request) -> Response:
457
+ index = _state(request).index
458
+ entry = index.services.get(service)
459
+ if entry is None:
460
+ raise _fail(404, "unknown_service", f"no service {service}")
461
+ payload = {
462
+ "service": entry.key,
463
+ "facts": [f.document for f in entry.facts],
464
+ "registry": _registry(index, entry),
465
+ }
466
+ return _cached(request, payload)
467
+
468
+ @app.get("/v0/facts/{service}/{slug}", response_model=FactOut, responses=_NOT_FOUND)
469
+ def fact(service: str, slug: str, request: Request) -> Response:
470
+ found = _state(request).index.fact(service, slug)
471
+ if found is None:
472
+ raise _fail(404, "unknown_fact", f"no fact af:{service}:{slug}")
473
+ return _cached(request, found.document)
474
+
475
+ @app.get("/healthz", response_model=Health)
476
+ def healthz(request: Request) -> dict[str, Any]:
477
+ state = _state(request)
478
+ return {
479
+ "ok": True,
480
+ "registry_version": state.index.version,
481
+ "probes_last_run": last_probe_run(state.results),
482
+ }
483
+
484
+ return app
485
+
486
+
487
+ app = create_app()