hypgraph 0.7.6__tar.gz → 0.7.8__tar.gz
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.
- {hypgraph-0.7.6 → hypgraph-0.7.8}/CHANGELOG.md +21 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/PKG-INFO +1 -1
- {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/spec.md +1 -1
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/__init__.py +3 -2
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/cli.py +3 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/graph.py +15 -0
- hypgraph-0.7.8/src/hypgraph/references.py +173 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/schema.py +4 -2
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/server.py +40 -5
- hypgraph-0.7.8/tests/test_references.py +156 -0
- hypgraph-0.7.8/tests/test_server_hosts.py +65 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/.gitignore +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/AGENTS.md +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/CITATION.cff +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/LICENSE +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/NOTICE +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/README.md +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/PLAN.md +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/decisions/ADR-002-governance-profiles.md +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/profile-workflows-plan.md +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/review-2026-10-24.md +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/site-plan.md +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/ui-plan.md +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/workflows/README.md +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/workflows/knowledge.md +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/workflows/optimization.md +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/workflows/scientific.md +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/workflows/software.md +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/workflows/trading.md +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/pyproject.toml +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/__main__.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/bundle.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/checks.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/coord.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/demos/__init__.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/demos/broad_street.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/derive.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/language.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/mcp.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/plan.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/policy.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/profiles.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/refusals.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/report_check.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/skill/SKILL.md +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/stats.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/store.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/ui/__init__.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/ui/atlas.css +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/ui/atlas.js +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/ui/hypgraph.css +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/ui/hypgraph.js +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/ui/hypgraph.svg +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/ui/index.html +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/ui/vendor/cose-base.js +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/ui/vendor/layout-base.js +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/workflow.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_accept_work_kind.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_bound_support.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_bundle_telemetry.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_cancel_releases_lease.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_cited_and_failed_support.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_coord.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_d10.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_engine.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_engine_fixes.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_incremental.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_link_translate.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_log_and_check.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_measure_questions.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_opposed_answers.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_option_lead.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_order_detail.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_profiles.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_profiles_041.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_questions_not_assertions.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_refusal_friction.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_refusal_results.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_refusals.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_relation_words.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_report_answers.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_rerunnable.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_restates_scale.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_server_auth.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_shape_refusals.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_stats_not_backtests.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_stats_values.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_surfaces.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_telemetry_stream.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_ui_app.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_ui_engine.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_uncorrected_effect.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_unnamed_inputs.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_versions_not_figures.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_vocabulary_friction.py +0 -0
- {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_workflows.py +0 -0
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.7.8 — 2026-10-29: pinned references between records
|
|
4
|
+
|
|
5
|
+
- **A record can cite a node of another record and prove what it read.** A `source` takes four more fields,
|
|
6
|
+
`record` (the cited record's URL), `node`, `version` and `hash` (sha256 of that version's canonical body, the
|
|
7
|
+
same hash the cited record's chain holds), all four or none (`SHAPE_REFERENCE` otherwise). `Graph.pin(node,
|
|
8
|
+
record_url)` / `GET /api/graph/pin/{id}?record=` build them for one of this record's nodes, with the node's own
|
|
9
|
+
words in `citation` and `says`, so the citing record reads without the cited one. `Graph.references(inv)` /
|
|
10
|
+
`GET /api/graph/references` list the sources here that pin other records, with who cites them.
|
|
11
|
+
`hypgraph.verify_reference(fields, fetch=)` reads the cited record's node route (HTTP, or a host's own reader)
|
|
12
|
+
and reports whether the pinned version still hashes to the pin, whether it was revised since, and whether it
|
|
13
|
+
was withdrawn. The citing record never trusts the other's status; it trusts the hash it pinned. A record stays
|
|
14
|
+
one trust boundary; a host that holds many (a mesh) derives the network from these pins.
|
|
15
|
+
|
|
16
|
+
## 0.7.7 — 2026-10-29: `hypgraph serve` inside a container
|
|
17
|
+
|
|
18
|
+
- **`hypgraph serve` bound to `0.0.0.0` answered 421 to everyone.** Its Host check knew only the bind address,
|
|
19
|
+
which no browser sends: inside a container with a published port, every request arrives as
|
|
20
|
+
`Host: 127.0.0.1:<host port>` and was refused as a possible DNS rebinding. A wildcard bind now accepts the
|
|
21
|
+
loopback names (the port was never part of the check), and `--allow-host NAME` (repeatable; `allow_hosts=` on
|
|
22
|
+
`make_server` / `serve`) adds a LAN or service name. A wildcard bind still never means "any Host".
|
|
2
23
|
|
|
3
24
|
## 0.7.6 — 2026-10-29: the swarm as swimlanes, and the UI walked on a real record
|
|
4
25
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hypgraph
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.8
|
|
4
4
|
Summary: The evidence layer for AI research agents: a hash-chained research record that checks every claim as it is written and derives how well each is known.
|
|
5
5
|
Project-URL: Homepage, https://hypgraph.com
|
|
6
6
|
Project-URL: Documentation, https://hypgraph.com/engineers/
|
|
@@ -28,7 +28,7 @@ correction is a new row, never an edited one.
|
|
|
28
28
|
| `run` | RUN | **kind**\*, job_id, name, state, code, inputs, outputs, args, config, seed, env, started, finished, exit_code, log_sha256, note | One execution: code, inputs and outputs hashed, seed, state. `kind=lab` only from an instrument. |
|
|
29
29
|
| `result` | RS | **summary**\*, kind, outcome, values, expected, limitations, power, evidence_note, legacy | What a run (or an observation, or a citation) found: values, outcome, power. |
|
|
30
30
|
| `artifact` | A | **sha256**\*, path, size, format | Exact bytes, by sha256 — data, code, a model, a figure. |
|
|
31
|
-
| `source` | S | **citation**\*, doi, url, passage, retrieved, says | Outside literature or data: citation, DOI, the passage that says it. |
|
|
31
|
+
| `source` | S | **citation**\*, doi, url, passage, retrieved, says, kind, reliability, record, node, version, hash | Outside literature or data: citation, DOI, the passage that says it. Or another record's node, pinned: `record` (its URL), `node`, `version` and `hash` (sha256 of that version's canonical body) — all four or none. |
|
|
32
32
|
| `review` | RV | **verdict**\*, **method**\*, **notes**\*, official | A verdict on one version of a claim, result, run, experiment or hypothesis. |
|
|
33
33
|
| `decision` | D | **choice**\*, alternatives, **reasons**\*, revisit_if, meeting | A choice made on the evidence; flagged when what it was based on changes. |
|
|
34
34
|
| `task` | T | **kanban_id**\*, title | A unit of work in your tracker, linked to what it works on. |
|
|
@@ -16,13 +16,14 @@ Standing is derived on every read, never stored; every write is an event in a SH
|
|
|
16
16
|
their numbers; reviews pin versions; independence is counted, not assumed. See https://hypgraph.com.
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
|
-
__version__ = "0.7.
|
|
19
|
+
__version__ = "0.7.8"
|
|
20
20
|
|
|
21
21
|
from .bundle import verify_bundle # noqa: E402
|
|
22
22
|
from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
|
|
23
23
|
from .policy import Policy # noqa: E402
|
|
24
|
+
from .references import verify_reference # noqa: E402
|
|
24
25
|
from .schema import FORMAT, describe # noqa: E402
|
|
25
26
|
from .store import verify_file # noqa: E402
|
|
26
27
|
|
|
27
|
-
__all__ = ["Graph", "GraphError", "Policy", "Author", "verify_bundle", "verify_file", "file_sha256", "describe",
|
|
28
|
+
__all__ = ["Graph", "GraphError", "Policy", "Author", "verify_bundle", "verify_file", "file_sha256", "describe", "verify_reference",
|
|
28
29
|
"FORMAT", "__version__"]
|
|
@@ -425,6 +425,7 @@ def cmd_serve(args) -> int:
|
|
|
425
425
|
if tokens and not args.writable:
|
|
426
426
|
print("note: --token only matters with --writable (reads are open)")
|
|
427
427
|
serve(args.db, host=args.host, port=args.port, writable=args.writable, open_browser=not args.no_browser,
|
|
428
|
+
allow_hosts=tuple(args.allow_host or ()),
|
|
428
429
|
tokens=tokens or None, telemetry=(args.telemetry or "auto") if not args.no_telemetry else None)
|
|
429
430
|
return 0
|
|
430
431
|
|
|
@@ -537,6 +538,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
537
538
|
s = cmd("serve", cmd_serve, "the HypGraph UI and JSON API on localhost")
|
|
538
539
|
s.add_argument("--host", default="127.0.0.1")
|
|
539
540
|
s.add_argument("--port", type=int, default=8765)
|
|
541
|
+
s.add_argument("--allow-host", action="append", metavar="NAME",
|
|
542
|
+
help="a Host header name to answer to besides loopback (a LAN or service name); repeatable")
|
|
540
543
|
s.add_argument("--writable", action="store_true", help="also accept writes (POST /api/graph/write)")
|
|
541
544
|
s.add_argument("--no-browser", action="store_true")
|
|
542
545
|
s.add_argument("--telemetry", default="", metavar="FILE",
|
|
@@ -28,6 +28,7 @@ from typing import Any, Callable, Optional
|
|
|
28
28
|
|
|
29
29
|
from . import profiles
|
|
30
30
|
from . import derive as D
|
|
31
|
+
from . import references as R
|
|
31
32
|
from . import refusals, store
|
|
32
33
|
from .checks import claim_issues, value_issues
|
|
33
34
|
from .policy import Policy
|
|
@@ -158,6 +159,12 @@ def clean_body(ntype: str, body: dict, *, partial_ok: bool = False) -> dict:
|
|
|
158
159
|
if len(canon(v)) > n:
|
|
159
160
|
raise _shape("SHAPE_TOO_LONG", f"{k} is too long", words=[k])
|
|
160
161
|
out[k] = v
|
|
162
|
+
if ntype == "source":
|
|
163
|
+
try:
|
|
164
|
+
out = R.check_fields(out)
|
|
165
|
+
except ValueError as e:
|
|
166
|
+
raise _shape("SHAPE_REFERENCE", str(e), "pin another record's node with record, node, version and hash "
|
|
167
|
+
"(its /api/graph/pin/<id>?record=<url> gives them)", ["record", "node", "version", "hash"])
|
|
161
168
|
return out
|
|
162
169
|
|
|
163
170
|
|
|
@@ -1225,6 +1232,14 @@ class Graph:
|
|
|
1225
1232
|
out["versions"] = list(s.versions.get(nid, []))
|
|
1226
1233
|
return out
|
|
1227
1234
|
|
|
1235
|
+
def pin(self, nid: str, record: str) -> dict:
|
|
1236
|
+
"""Source fields another record can cite this node by: its version and hash, pinned (references.pin)."""
|
|
1237
|
+
return R.pin(self, nid, record)
|
|
1238
|
+
|
|
1239
|
+
def references(self, inv: str = "") -> list[dict]:
|
|
1240
|
+
"""The sources here that pin nodes of other records, with who cites them (references.references)."""
|
|
1241
|
+
return R.references(self, inv)
|
|
1242
|
+
|
|
1228
1243
|
def resolve(self, ref: str, inv: str = "") -> str:
|
|
1229
1244
|
"""A node id from an id or a display code (CL3, H2, X4 …), within an investigation when given."""
|
|
1230
1245
|
ref = str(ref or "").strip()
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Pinned references: one record citing a node of another record, so that records can form a network without
|
|
2
|
+
sharing a chain.
|
|
3
|
+
|
|
4
|
+
A record is one trust boundary: one hash chain, one policy, one board. Another record's claim can still be a line
|
|
5
|
+
of support here — as a ``source`` node, which is what a cited outside work already is — provided the reference
|
|
6
|
+
says *which version* it read and *what that version hashed to*. The fields on ``source``:
|
|
7
|
+
|
|
8
|
+
record the served record's base URL (what its /api/graph hangs off), e.g. https://mesh/q/demo-a
|
|
9
|
+
node the node id there
|
|
10
|
+
version the version that was read
|
|
11
|
+
hash sha256 of that version's canonical body — the same hash the record's own chain holds for it
|
|
12
|
+
|
|
13
|
+
``pin`` builds those fields for one of this record's nodes; ``verify_reference`` fetches the cited record and checks
|
|
14
|
+
them: the hash still matches (nothing was rewritten), whether the node has been revised since (the pinned version
|
|
15
|
+
stands as read, but there is a newer one), and whether it has been withdrawn. A host that holds many records (a
|
|
16
|
+
mesh) uses the same functions against its own copies and derives cross-record standing from them. This record never
|
|
17
|
+
trusts another record's status; it trusts the hash it pinned."""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.parse
|
|
24
|
+
import urllib.request
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from typing import TYPE_CHECKING, Callable, Optional
|
|
27
|
+
|
|
28
|
+
from . import store
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from .graph import Graph
|
|
32
|
+
|
|
33
|
+
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
|
34
|
+
_URL = re.compile(r"^https?://[^\s/?#]+[^\s]*$", re.I)
|
|
35
|
+
Fetch = Callable[[str], dict]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def body_hash(body: dict) -> str:
|
|
39
|
+
"""The hash a record's chain holds for a version: sha256 over the canonical JSON of its body."""
|
|
40
|
+
return store.sha(store.canon(body))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def normalize_record(url: str) -> str:
|
|
44
|
+
"""A record URL without a trailing slash, fragment or query; refuses anything that is not http(s)."""
|
|
45
|
+
u = str(url or "").strip()
|
|
46
|
+
if not _URL.match(u):
|
|
47
|
+
raise ValueError(f"record must be the cited record's http(s) URL, not {u!r}")
|
|
48
|
+
p = urllib.parse.urlsplit(u)
|
|
49
|
+
return urllib.parse.urlunsplit((p.scheme.lower(), p.netloc, p.path.rstrip("/"), "", ""))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check_fields(body: dict) -> dict:
|
|
53
|
+
"""Validate the reference part of a source body (after the generic field cleaning). All four fields or none;
|
|
54
|
+
version a positive integer; hash 64 hex characters; record an http(s) URL. Returns the body with the fields
|
|
55
|
+
normalised."""
|
|
56
|
+
keys = ("record", "node", "version", "hash")
|
|
57
|
+
present = [k for k in keys if body.get(k) not in (None, "", 0)]
|
|
58
|
+
if not present:
|
|
59
|
+
return body
|
|
60
|
+
missing = [k for k in keys if k not in present]
|
|
61
|
+
if missing:
|
|
62
|
+
raise ValueError("a reference to another record names all of record, node, version and hash; missing: "
|
|
63
|
+
+ ", ".join(missing))
|
|
64
|
+
out = dict(body)
|
|
65
|
+
out["record"] = normalize_record(body["record"])
|
|
66
|
+
out["node"] = str(body["node"]).strip()
|
|
67
|
+
v = body["version"]
|
|
68
|
+
try:
|
|
69
|
+
fv = float(v)
|
|
70
|
+
except (TypeError, ValueError):
|
|
71
|
+
raise ValueError(f"version must be a positive whole number, not {v!r}")
|
|
72
|
+
if not fv.is_integer() or fv < 1:
|
|
73
|
+
raise ValueError(f"version must be a positive whole number, not {v!r}")
|
|
74
|
+
out["version"] = int(fv)
|
|
75
|
+
h = str(body["hash"]).strip().lower()
|
|
76
|
+
if not _HEX64.match(h):
|
|
77
|
+
raise ValueError("hash must be the 64-hex sha256 of the cited version's canonical body")
|
|
78
|
+
out["hash"] = h
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def is_reference(body: dict) -> bool:
|
|
83
|
+
return bool(body.get("record")) and bool(body.get("hash"))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def pin(g: "Graph", nid: str, record: str, *, now: str = "") -> dict:
|
|
87
|
+
"""Source fields that pin ``nid`` of ``g`` as it stands now, for another record to cite. ``record`` is the URL
|
|
88
|
+
this record is served at. The citation and ``says`` carry the node's own words, so the citing record reads
|
|
89
|
+
without the cited one."""
|
|
90
|
+
s = g.snapshot()
|
|
91
|
+
nid = g.resolve(nid)
|
|
92
|
+
n = s.nodes[nid]
|
|
93
|
+
rec = normalize_record(record)
|
|
94
|
+
brief = g.brief(s, nid)
|
|
95
|
+
title = brief["title"] or n["type"]
|
|
96
|
+
when = now or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
97
|
+
return {"citation": f"{brief['code']} ({n['type']}) in {rec}, version {n['v']}: {title}"[:1000],
|
|
98
|
+
"url": f"{rec}/#node={nid}", "kind": "primary", "retrieved": when,
|
|
99
|
+
"says": str(title)[:1500],
|
|
100
|
+
"record": rec, "node": nid, "version": int(n["v"]), "hash": body_hash(n["body"])}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def references(g: "Graph", inv: str = "") -> list[dict]:
|
|
104
|
+
"""Every source in ``g`` that pins a node of another record, with who cites it here. What a host walks to build
|
|
105
|
+
the network between records."""
|
|
106
|
+
s = g.snapshot()
|
|
107
|
+
out = []
|
|
108
|
+
for nid, n in s.nodes.items():
|
|
109
|
+
if n["type"] != "source" or not is_reference(n["body"]) or (inv and n["inv"] != inv):
|
|
110
|
+
continue
|
|
111
|
+
b = n["body"]
|
|
112
|
+
out.append({"id": nid, "code": n["code"], "inv": n["inv"], "record": b["record"], "node": b["node"],
|
|
113
|
+
"version": b["version"], "hash": b["hash"], "lifecycle": g.state(nid)["lifecycle"],
|
|
114
|
+
"cited_by": sorted({e["src"] for e in s.inn.get(nid, []) if e["rel"] == "cites"})})
|
|
115
|
+
out.sort(key=lambda r: (r["record"], r["node"], r["id"]))
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _http_fetch(timeout: float) -> Fetch:
|
|
120
|
+
def fetch(url: str) -> dict:
|
|
121
|
+
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
|
122
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
123
|
+
return json.load(r)
|
|
124
|
+
return fetch
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def verify_reference(fields: dict, *, fetch: Optional[Fetch] = None, timeout: float = 10.0) -> dict:
|
|
128
|
+
"""Check a pinned reference against the record it points at.
|
|
129
|
+
|
|
130
|
+
``fetch(url) -> dict`` answers ``GET <record>/api/graph/node/<node>?history=1``; the default is HTTP. A host
|
|
131
|
+
that holds the cited record passes its own reader. Returns::
|
|
132
|
+
|
|
133
|
+
{ok, problem, record, node, version, current_version, revised, withdrawn, lifecycle}
|
|
134
|
+
|
|
135
|
+
``ok`` means the pinned version exists there and its body hashes to the pinned hash: what this record read is
|
|
136
|
+
what that record holds. ``revised`` (a newer version exists) and ``withdrawn`` are reported beside it — the
|
|
137
|
+
pin still stands as read, but the citing record's host decides what that means for its standing."""
|
|
138
|
+
try:
|
|
139
|
+
f = check_fields({k: fields.get(k) for k in ("record", "node", "version", "hash")})
|
|
140
|
+
except ValueError as e:
|
|
141
|
+
return {"ok": False, "problem": str(e)}
|
|
142
|
+
if not is_reference(f):
|
|
143
|
+
return {"ok": False, "problem": "not a reference: no record and hash pinned"}
|
|
144
|
+
url = f"{f['record']}/api/graph/node/{urllib.parse.quote(f['node'], safe='')}?history=1"
|
|
145
|
+
out = {"ok": False, "record": f["record"], "node": f["node"], "version": f["version"], "url": url}
|
|
146
|
+
try:
|
|
147
|
+
doc = (fetch or _http_fetch(timeout))(url)
|
|
148
|
+
except urllib.error.HTTPError as e:
|
|
149
|
+
return {**out, "problem": f"the record answered {e.code} for {f['node']}"}
|
|
150
|
+
except Exception as e: # unreachable, bad JSON, timeout: the reference is unverified, not wrong
|
|
151
|
+
return {**out, "problem": f"could not read the record: {type(e).__name__}"}
|
|
152
|
+
if not isinstance(doc, dict) or "versions" not in doc:
|
|
153
|
+
return {**out, "problem": "the record's answer carries no versions (is it a HypGraph node route with history=1?)"}
|
|
154
|
+
versions = {int(v.get("v", 0)): v for v in doc.get("versions") or [] if isinstance(v, dict)}
|
|
155
|
+
cur = max(versions) if versions else int(doc.get("v") or 0)
|
|
156
|
+
out |= {"current_version": cur, "revised": cur > f["version"], "withdrawn": bool(doc.get("withdrawn")),
|
|
157
|
+
"lifecycle": str((doc.get("state") or {}).get("lifecycle") or ("withdrawn" if doc.get("withdrawn") else ""))}
|
|
158
|
+
ver = versions.get(f["version"])
|
|
159
|
+
if ver is None:
|
|
160
|
+
return {**out, "problem": f"version {f['version']} does not exist there (it has {cur})"}
|
|
161
|
+
body = ver.get("body")
|
|
162
|
+
if isinstance(body, str):
|
|
163
|
+
try:
|
|
164
|
+
body = json.loads(body)
|
|
165
|
+
except json.JSONDecodeError:
|
|
166
|
+
return {**out, "problem": "the version's body is not JSON"}
|
|
167
|
+
got = body_hash(body) if isinstance(body, dict) else ""
|
|
168
|
+
if got != f["hash"]:
|
|
169
|
+
return {**out, "problem": f"version {f['version']} hashes to {got[:16]}…, not the pinned {f['hash'][:16]}…"}
|
|
170
|
+
return {**out, "ok": True}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
__all__ = ["pin", "references", "verify_reference", "check_fields", "is_reference", "body_hash", "normalize_record"]
|
|
@@ -65,7 +65,9 @@ TYPES: dict[str, dict] = {
|
|
|
65
65
|
"source": {"prefix": "S", "fields": {
|
|
66
66
|
"citation": _f("text", 1000, True), "doi": _f("str", 200), "url": _f("str", 500), "passage": _f("text", 3000),
|
|
67
67
|
"retrieved": _f("str", 40), "says": _f("text", 1500), "kind": _f("enum:primary|secondary|tertiary"),
|
|
68
|
-
"reliability": _f("text", 1000)
|
|
68
|
+
"reliability": _f("text", 1000),
|
|
69
|
+
# a pinned reference to a node of another record (references.py): all four or none
|
|
70
|
+
"record": _f("str", 500), "node": _f("str", 80), "version": _f("num"), "hash": _f("str", 64)}},
|
|
69
71
|
"review": {"prefix": "RV", "fields": {
|
|
70
72
|
"verdict": _f("enum:" + "|".join(sorted(VERDICTS)), req=True), "method": _f("text", 1500, True),
|
|
71
73
|
"notes": _f("text", 4000, True), "official": _f("bool")}},
|
|
@@ -179,7 +181,7 @@ ABOUT = {
|
|
|
179
181
|
"run": "One execution: code, inputs and outputs hashed, seed, state. `kind=lab` only from an instrument.",
|
|
180
182
|
"result": "What a run (or an observation, or a citation) found: values, outcome, power.",
|
|
181
183
|
"artifact": "Exact bytes, by sha256 — data, code, a model, a figure.",
|
|
182
|
-
"source": "Outside literature or data: citation, DOI, the passage that says it.",
|
|
184
|
+
"source": "Outside literature or data: citation, DOI, the passage that says it — or another record's node, pinned by version and hash.",
|
|
183
185
|
"review": "A verdict on one version of a claim, result, run, experiment or hypothesis.",
|
|
184
186
|
"decision": "A choice made on the evidence; flagged when what it was based on changes.",
|
|
185
187
|
"task": "A unit of work linked to what it works on — or a work order: objective, assignee, done-when "
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
GET /api/graph/investigations {investigations, stats}
|
|
6
6
|
GET /api/graph/subgraph?inv=&types= nodes (with standing) and live edges
|
|
7
7
|
GET /api/graph/node/{id}?history=1 one node, its links, its versions
|
|
8
|
+
GET /api/graph/pin/{id}?record=URL source fields another record cites this node by (version + hash pinned)
|
|
9
|
+
GET /api/graph/references?inv= the sources here that pin nodes of other records
|
|
8
10
|
GET /api/graph/why/{id} what it rests on /impact/{id} what rests on it
|
|
9
11
|
GET /api/graph/neighborhood/{id}?depth=
|
|
10
12
|
GET /api/graph/search?text=&type=&inv=&limit=
|
|
@@ -160,6 +162,15 @@ def dispatch(g: Graph, method: str, path: str, query: dict | None = None, body:
|
|
|
160
162
|
return 200, g.subgraph(inv, types=types)
|
|
161
163
|
if head == "node" and arg:
|
|
162
164
|
return 200, g.get(arg, history=q.get("history") in ("1", "true"))
|
|
165
|
+
if head == "pin" and arg:
|
|
166
|
+
if not q.get("record"):
|
|
167
|
+
return 400, {"detail": "record=<the URL this record is served at> is required"}
|
|
168
|
+
try:
|
|
169
|
+
return 200, g.pin(arg, q["record"])
|
|
170
|
+
except ValueError as e:
|
|
171
|
+
return 400, {"detail": str(e)}
|
|
172
|
+
if head == "references":
|
|
173
|
+
return 200, {"references": g.references(inv)}
|
|
163
174
|
if head == "why" and arg:
|
|
164
175
|
return 200, g.why(arg)
|
|
165
176
|
if head == "impact" and arg:
|
|
@@ -236,6 +247,28 @@ def read_telemetry(path: Path | None, after: int = 0, inv: str = "", limit: int
|
|
|
236
247
|
return out, after + end + 1
|
|
237
248
|
|
|
238
249
|
|
|
250
|
+
LOOPBACK_NAMES = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
251
|
+
WILDCARD_BINDS = frozenset({"", "0.0.0.0", "::", "*"})
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def accepted_hosts(host: str, allow_hosts: tuple[str, ...] | list[str] = ()) -> set[str]:
|
|
255
|
+
"""The names a request's Host header may carry (port ignored); anything else is 421.
|
|
256
|
+
|
|
257
|
+
Bound to a loopback name, the server answers to every loopback name. Bound to a wildcard (``0.0.0.0``,
|
|
258
|
+
``::``) — as inside a container whose port is published — it answers to the loopback names too, since the
|
|
259
|
+
browser reaches the published port as ``127.0.0.1:<host port>``; the bind address itself is never a name a
|
|
260
|
+
browser sends. ``allow_hosts`` adds names: a LAN hostname, a service name. The check is DNS-rebinding
|
|
261
|
+
defence (a page on another origin can't make the browser talk to this server by name), so a wildcard bind
|
|
262
|
+
never means "any Host"."""
|
|
263
|
+
names = {h.strip().lower() for h in allow_hosts if h and h.strip()}
|
|
264
|
+
h = (host or "").strip().lower()
|
|
265
|
+
if h in LOOPBACK_NAMES or h in WILDCARD_BINDS:
|
|
266
|
+
names |= LOOPBACK_NAMES
|
|
267
|
+
if h and h not in WILDCARD_BINDS:
|
|
268
|
+
names.add(h)
|
|
269
|
+
return names
|
|
270
|
+
|
|
271
|
+
|
|
239
272
|
def make_handler(g: Graph, *, writable: bool, hosts: set[str], root: Path,
|
|
240
273
|
tokens: dict[str, frozenset] | None = None, telemetry: Path | None = None):
|
|
241
274
|
class Handler(BaseHTTPRequestHandler):
|
|
@@ -389,7 +422,8 @@ def make_handler(g: Graph, *, writable: bool, hosts: set[str], root: Path,
|
|
|
389
422
|
|
|
390
423
|
|
|
391
424
|
def make_server(graph: Graph | str, host: str = "127.0.0.1", port: int = 8765, *, writable: bool = False,
|
|
392
|
-
tokens: dict[str, frozenset] | None = None, telemetry: str | Path | None = "auto"
|
|
425
|
+
tokens: dict[str, frozenset] | None = None, telemetry: str | Path | None = "auto",
|
|
426
|
+
allow_hosts: tuple[str, ...] | list[str] = ()):
|
|
393
427
|
"""``telemetry``: the swarm's sidecar to serve and stream — "auto" is ``<db>.telemetry.jsonl`` beside the record
|
|
394
428
|
(it may appear later: a run started after the server is picked up), a path, or None for none."""
|
|
395
429
|
g = graph if isinstance(graph, Graph) else Graph(graph)
|
|
@@ -399,19 +433,20 @@ def make_server(graph: Graph | str, host: str = "127.0.0.1", port: int = 8765, *
|
|
|
399
433
|
tel = Path(g.path + ".telemetry.jsonl")
|
|
400
434
|
elif telemetry:
|
|
401
435
|
tel = Path(telemetry)
|
|
402
|
-
hosts =
|
|
403
|
-
{host.lower()}
|
|
436
|
+
hosts = accepted_hosts(host, allow_hosts)
|
|
404
437
|
root = static_dir().resolve()
|
|
405
438
|
return ThreadingHTTPServer((host, port), make_handler(g, writable=writable, hosts=hosts, root=root, tokens=tokens,
|
|
406
439
|
telemetry=tel))
|
|
407
440
|
|
|
408
441
|
|
|
409
442
|
def serve(db: str, host: str = "127.0.0.1", port: int = 8765, *, writable: bool = False,
|
|
410
|
-
open_browser: bool = True, tokens: dict[str, frozenset] | None = None, telemetry: str | None = "auto"
|
|
443
|
+
open_browser: bool = True, tokens: dict[str, frozenset] | None = None, telemetry: str | None = "auto",
|
|
444
|
+
allow_hosts: tuple[str, ...] | list[str] = ()) -> None:
|
|
411
445
|
if not Path(db).exists():
|
|
412
446
|
print(f"{db} doesn't exist yet — `hypgraph demo` builds one to explore, `hypgraph init` starts your own.")
|
|
413
447
|
return
|
|
414
|
-
srv = make_server(db, host, port, writable=writable, tokens=tokens, telemetry=telemetry
|
|
448
|
+
srv = make_server(db, host, port, writable=writable, tokens=tokens, telemetry=telemetry,
|
|
449
|
+
allow_hosts=allow_hosts)
|
|
415
450
|
url = f"http://{host}:{srv.server_address[1]}/"
|
|
416
451
|
auth = f", {len(tokens)} write token(s)" if tokens else (", writes unauthenticated" if writable else "")
|
|
417
452
|
tel = telemetry if telemetry not in ("auto", None) else (db + ".telemetry.jsonl" if telemetry == "auto" else "")
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Pinned references between records: record B cites a node of record A as a source that names the version it read
|
|
2
|
+
and that version's hash. The pin is built by A (``pin``), checked against A (``verify_reference``), and listed by B
|
|
3
|
+
(``references``). A is read the way a host reads it: through the API's node route with history."""
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
import urllib.request
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from hypgraph import Graph, GraphError, Policy, verify_reference
|
|
11
|
+
from hypgraph import derive as D
|
|
12
|
+
from hypgraph.references import body_hash, check_fields, normalize_record
|
|
13
|
+
from hypgraph.server import dispatch, make_server
|
|
14
|
+
|
|
15
|
+
RECORD_A = "https://mesh.example.org/q/demo-a"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def a():
|
|
20
|
+
"""Record A: one checked claim with a bound measurement."""
|
|
21
|
+
g = Graph(policy=Policy(reviewers={"critic"}, instrument="lab"))
|
|
22
|
+
me = g.author("ada")
|
|
23
|
+
inv = me.investigation("Treatment effect", "Does the treatment group score higher?")["id"]
|
|
24
|
+
run = me.add("run", inv, kind="agent", name="scores", state="succeeded")["id"]
|
|
25
|
+
r = me.add("result", inv, kind="computed", outcome="positive", summary="The treatment group scored higher in 41 of 50 pairs.",
|
|
26
|
+
values={"higher": {"k": 41, "n": 50}}, links=[{"rel": "produced", "from": run}])["id"]
|
|
27
|
+
c = me.add("claim", inv, statement="The treatment group scores higher in 41 of 50 matched pairs.",
|
|
28
|
+
scope="The 50 matched pairs in the pilot data.",
|
|
29
|
+
measurements=[{"name": "higher", "result": r, "key": "higher", "k": 41, "n": 50}],
|
|
30
|
+
links=[{"rel": "supports", "from": r, "why": "the measurement the claim states"}])["id"]
|
|
31
|
+
return g, inv, c
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def reader_of(g):
|
|
35
|
+
"""A fetch that reads ``g`` the way a host reads a served record: through the node route with history."""
|
|
36
|
+
def fetch(url):
|
|
37
|
+
assert url.startswith(RECORD_A + "/api/graph/"), url
|
|
38
|
+
path, _, query = url[len(RECORD_A + "/api/graph"):].partition("?")
|
|
39
|
+
q = {k: [v] for k, v in (kv.split("=", 1) for kv in query.split("&") if kv)}
|
|
40
|
+
status, obj = dispatch(g, "GET", path, query=q)
|
|
41
|
+
assert status == 200, (status, obj)
|
|
42
|
+
return json.loads(json.dumps(obj, default=str))
|
|
43
|
+
return fetch
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_pin_carries_version_hash_and_the_nodes_own_words(a):
|
|
47
|
+
g, inv, c = a
|
|
48
|
+
p = g.pin("CL1", RECORD_A + "/") # a display code resolves; the trailing slash goes
|
|
49
|
+
assert p["record"] == RECORD_A and p["node"] == c and p["version"] == 1
|
|
50
|
+
assert p["hash"] == body_hash(g.get(c)["body"]) and len(p["hash"]) == 64
|
|
51
|
+
assert "41 of 50" in p["says"] and p["citation"].startswith("CL1 (claim) in " + RECORD_A)
|
|
52
|
+
assert p["url"] == f"{RECORD_A}/#node={c}" and p["kind"] == "primary" and p["retrieved"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_b_cites_a_and_the_reference_is_a_line_of_support(a):
|
|
56
|
+
g_a, _, c = a
|
|
57
|
+
g_b = Graph()
|
|
58
|
+
bob = g_b.author("bob")
|
|
59
|
+
inv = bob.investigation("Follow-up", "Does the effect hold at scale?")["id"]
|
|
60
|
+
s = bob.add("source", inv, **g_a.pin(c, RECORD_A))
|
|
61
|
+
assert s["body"]["version"] == 1 and isinstance(s["body"]["version"], int)
|
|
62
|
+
cl = bob.add("claim", inv, statement="A pilot found the treatment group higher in 41 of 50 matched pairs.",
|
|
63
|
+
scope="The pilot's 50 matched pairs, as recorded in demo-a.", links=[{"rel": "cites", "to": s["id"]}])
|
|
64
|
+
st = D.state(g_b.snapshot(), cl["id"])
|
|
65
|
+
assert st["independent_lines"] == 1 and st["evidence"] == "external"
|
|
66
|
+
refs = g_b.references()
|
|
67
|
+
assert len(refs) == 1 and refs[0]["record"] == RECORD_A and refs[0]["node"] == c and refs[0]["cited_by"] == [cl["id"]]
|
|
68
|
+
assert g_b.references("inv-nothing") == []
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_verify_against_the_cited_record(a):
|
|
72
|
+
g_a, _, c = a
|
|
73
|
+
fields = g_a.pin(c, RECORD_A)
|
|
74
|
+
v = verify_reference(fields, fetch=reader_of(g_a))
|
|
75
|
+
assert v["ok"] and v["current_version"] == 1 and not v["revised"] and not v["withdrawn"] and v["lifecycle"] == "active"
|
|
76
|
+
|
|
77
|
+
g_a.author("ada").revise(c, "the scope was too narrow", scope="The 50 matched pairs in the pilot data, both sites.")
|
|
78
|
+
v = verify_reference(fields, fetch=reader_of(g_a))
|
|
79
|
+
assert v["ok"] and v["revised"] and v["current_version"] == 2 # what was read still stands as read
|
|
80
|
+
|
|
81
|
+
g_a.author("ada").withdraw(c, "the pairs were matched after the scores were seen")
|
|
82
|
+
v = verify_reference(fields, fetch=reader_of(g_a))
|
|
83
|
+
assert v["ok"] and v["withdrawn"] and v["lifecycle"] == "withdrawn"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_a_rewritten_body_or_a_wrong_pin_fails(a):
|
|
87
|
+
g_a, _, c = a
|
|
88
|
+
fields = g_a.pin(c, RECORD_A)
|
|
89
|
+
bad = {**fields, "hash": "0" * 64}
|
|
90
|
+
v = verify_reference(bad, fetch=reader_of(g_a))
|
|
91
|
+
assert not v["ok"] and "hashes to" in v["problem"]
|
|
92
|
+
v = verify_reference({**fields, "version": 7}, fetch=reader_of(g_a))
|
|
93
|
+
assert not v["ok"] and "version 7 does not exist" in v["problem"]
|
|
94
|
+
v = verify_reference({**fields, "node": "n-nowhere"}, fetch=reader_of(g_a))
|
|
95
|
+
assert not v["ok"] and "problem" in v
|
|
96
|
+
|
|
97
|
+
def down(url):
|
|
98
|
+
raise OSError("connection refused")
|
|
99
|
+
v = verify_reference(fields, fetch=down)
|
|
100
|
+
assert not v["ok"] and v["problem"].startswith("could not read the record")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_the_schema_refuses_half_a_reference(a):
|
|
104
|
+
g_a, _, c = a
|
|
105
|
+
g_b = Graph()
|
|
106
|
+
bob = g_b.author("bob")
|
|
107
|
+
inv = bob.investigation("Follow-up", "Does the effect hold at scale?")["id"]
|
|
108
|
+
p = g_a.pin(c, RECORD_A)
|
|
109
|
+
with pytest.raises(GraphError) as e:
|
|
110
|
+
bob.add("source", inv, **{k: v for k, v in p.items() if k != "hash"})
|
|
111
|
+
assert "missing: hash" in str(e.value)
|
|
112
|
+
with pytest.raises(GraphError) as e:
|
|
113
|
+
bob.add("source", inv, **{**p, "hash": "nope"})
|
|
114
|
+
assert "64-hex" in str(e.value)
|
|
115
|
+
with pytest.raises(GraphError) as e:
|
|
116
|
+
bob.add("source", inv, **{**p, "version": 1.5})
|
|
117
|
+
assert "whole number" in str(e.value)
|
|
118
|
+
with pytest.raises(GraphError):
|
|
119
|
+
bob.add("source", inv, **{**p, "record": "ftp://mesh/q/a"})
|
|
120
|
+
plain = bob.add("source", inv, citation="A plain outside source, cited by its DOI.", doi="10.1000/xyz")
|
|
121
|
+
assert "record" not in plain["body"] and g_b.references() == []
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_check_fields_and_normalize_record():
|
|
125
|
+
assert normalize_record("HTTPS://Mesh.example.org/q/a/?x=1#node=n") == "https://Mesh.example.org/q/a"
|
|
126
|
+
assert check_fields({"citation": "x"}) == {"citation": "x"}
|
|
127
|
+
out = check_fields({"record": "https://m/q/a", "node": " n1 ", "version": 2.0, "hash": "A" * 64})
|
|
128
|
+
assert out["version"] == 2 and out["node"] == "n1" and out["hash"] == "a" * 64
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_the_routes_pin_and_list(a, tmp_path):
|
|
132
|
+
g_a, inv, c = a
|
|
133
|
+
srv = make_server(g_a, "127.0.0.1", 0)
|
|
134
|
+
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
135
|
+
base = f"http://127.0.0.1:{srv.server_address[1]}"
|
|
136
|
+
try:
|
|
137
|
+
with urllib.request.urlopen(f"{base}/api/graph/pin/{c}?record={RECORD_A}") as r:
|
|
138
|
+
p = json.load(r)
|
|
139
|
+
assert p["hash"] == body_hash(g_a.get(c)["body"]) and p["version"] == 1
|
|
140
|
+
try:
|
|
141
|
+
urllib.request.urlopen(f"{base}/api/graph/pin/{c}")
|
|
142
|
+
assert False, "record= is required"
|
|
143
|
+
except urllib.error.HTTPError as e:
|
|
144
|
+
assert e.code == 400
|
|
145
|
+
g_b = Graph(str(tmp_path / "b.hypgraph"))
|
|
146
|
+
bob = g_b.author("bob")
|
|
147
|
+
inv_b = bob.investigation("Follow-up", "Does the effect hold at scale?")["id"]
|
|
148
|
+
bob.add("source", inv_b, **p)
|
|
149
|
+
status, obj = dispatch(g_b, "GET", "/references", query={"inv": [inv_b]})
|
|
150
|
+
assert status == 200 and [x["node"] for x in obj["references"]] == [c]
|
|
151
|
+
# verified over real HTTP, the way a mesh or another host does it
|
|
152
|
+
v = verify_reference({**p, "record": base})
|
|
153
|
+
assert v["ok"] and not v["revised"]
|
|
154
|
+
finally:
|
|
155
|
+
srv.shutdown()
|
|
156
|
+
g_b.close()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Which Host headers `serve` answers to. Bound to loopback: loopback names. Bound to a wildcard (a container
|
|
2
|
+
with a published port): loopback names too — the browser reaches the published port as 127.0.0.1:<host port>, and
|
|
3
|
+
the bind address is never a name a browser sends. Anything else is 421, unless --allow-host names it."""
|
|
4
|
+
import threading
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from hypgraph import Graph
|
|
11
|
+
from hypgraph.server import accepted_hosts, make_server
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get(url, host_header):
|
|
15
|
+
req = urllib.request.Request(f"{url}/api/graph/investigations", headers={"Host": host_header})
|
|
16
|
+
try:
|
|
17
|
+
with urllib.request.urlopen(req) as r:
|
|
18
|
+
return r.status
|
|
19
|
+
except urllib.error.HTTPError as e:
|
|
20
|
+
return e.code
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def serve_on(tmp_path):
|
|
25
|
+
servers = []
|
|
26
|
+
|
|
27
|
+
def start(host, **kw):
|
|
28
|
+
g = Graph(str(tmp_path / f"{len(servers)}.hypgraph"))
|
|
29
|
+
srv = make_server(g, host, port=0, **kw)
|
|
30
|
+
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
31
|
+
servers.append(srv)
|
|
32
|
+
return f"http://127.0.0.1:{srv.server_address[1]}"
|
|
33
|
+
|
|
34
|
+
yield start
|
|
35
|
+
for srv in servers:
|
|
36
|
+
srv.shutdown()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_accepted_hosts_rules():
|
|
40
|
+
assert accepted_hosts("127.0.0.1") == {"127.0.0.1", "localhost", "::1"}
|
|
41
|
+
assert accepted_hosts("0.0.0.0") == {"127.0.0.1", "localhost", "::1"} # the bind address is not a name
|
|
42
|
+
assert accepted_hosts("::") == {"127.0.0.1", "localhost", "::1"}
|
|
43
|
+
assert accepted_hosts("lab.local") == {"lab.local"} # a real name: that name only
|
|
44
|
+
assert accepted_hosts("0.0.0.0", ["Swarm.Internal", " "]) == {"127.0.0.1", "localhost", "::1", "swarm.internal"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_loopback_bind_answers_loopback_names_and_refuses_others(serve_on):
|
|
48
|
+
url = serve_on("127.0.0.1")
|
|
49
|
+
assert _get(url, "127.0.0.1:9") == 200 # the port is not part of the check
|
|
50
|
+
assert _get(url, "localhost") == 200
|
|
51
|
+
assert _get(url, "evil.example") == 421
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_wildcard_bind_answers_loopback_through_a_published_port(serve_on):
|
|
55
|
+
url = serve_on("0.0.0.0")
|
|
56
|
+
assert _get(url, "127.0.0.1:8100") == 200 # the host side of a container's published port
|
|
57
|
+
assert _get(url, "localhost:8100") == 200
|
|
58
|
+
assert _get(url, "0.0.0.0:8000") == 421 # no browser sends the bind address; a rebinding page might
|
|
59
|
+
assert _get(url, "evil.example") == 421
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_allow_host_adds_a_name(serve_on):
|
|
63
|
+
url = serve_on("0.0.0.0", allow_hosts=["swarm.internal"])
|
|
64
|
+
assert _get(url, "swarm.internal:8000") == 200
|
|
65
|
+
assert _get(url, "other.internal") == 421
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|