Skip to content

Commit 1235cbf

Browse files
rustyconoverclaude
andcommitted
feat(landing): standardized VGI landing surface + _vgi_identity cookie
Add a landingDescribe provider hook: serve the shared self-contained landing.html (vendored byte-identical from vgi-web-frontend) with content negotiation, plus GET {prefix}/describe.json and the lazy column endpoint. At the OAuth callback decode the OIDC id_token and set a JS-readable `_vgi_identity` cookie (display claims only) that the shared page reads; clear it on logout; remove the old per-language user-info pill injection. Add a landing-conformance CI job. Bump 0.9.0 -> 0.10.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 073af7d commit 1235cbf

26 files changed

Lines changed: 7234 additions & 7323 deletions

.github/workflows/ci.yml

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ jobs:
3535
run: bun install && bun run postinstall
3636

3737
- name: Run unit tests
38-
run: bun test test/wire.test.ts test/describe.test.ts test/schema.test.ts test/output-collector.test.ts test/http/handler.test.ts test/http/token.test.ts
38+
run: bun test test/wire.test.ts test/describe.test.ts test/schema.test.ts test/output-collector.test.ts test/http/handler.test.ts test/http/landing.test.ts test/http/oauth-pkce.test.ts test/http/token.test.ts
3939

4040
- name: Run client tests (bun transports only)
4141
run: timeout 120 bun test test/client.test.ts
@@ -120,6 +120,43 @@ jobs:
120120
- name: Smoke test (Deno)
121121
run: timeout 60 deno run --allow-all .smoke-bundle/smoke.js
122122

123+
landing-conformance:
124+
runs-on: ubuntu-latest
125+
steps:
126+
- uses: actions/checkout@v7
127+
128+
- uses: oven-sh/setup-bun@v2
129+
with:
130+
bun-version: latest
131+
132+
- uses: actions/setup-python@v6
133+
with:
134+
python-version: "3.14"
135+
136+
- name: Install JS dependencies
137+
run: bun install && bun run postinstall
138+
139+
- name: Install Python dependencies
140+
run: pip install jsonschema
141+
142+
- name: Boot landing worker and run landing conformance
143+
run: |
144+
bun run examples/conformance-landing.ts > /tmp/landing-port.txt &
145+
WORKER_PID=$!
146+
for i in $(seq 1 30); do
147+
PORT=$(grep -o 'PORT:[0-9]*' /tmp/landing-port.txt | cut -d: -f2 || true)
148+
[ -n "$PORT" ] && break
149+
sleep 0.5
150+
done
151+
test -n "$PORT" || { echo "worker did not report a port"; cat /tmp/landing-port.txt; exit 1; }
152+
echo "worker on port $PORT"
153+
# The runner + describe.schema.json are vendored under conformance/;
154+
# DEFAULT_SCHEMA resolves next to the script.
155+
python conformance/run_landing_conformance.py --url "http://localhost:$PORT"
156+
STATUS=$?
157+
kill $WORKER_PID 2>/dev/null || true
158+
exit $STATUS
159+
123160
conformance:
124161
runs-on: ubuntu-latest
125162
steps:

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
PYTHON ?= /Users/rusty/Development/vgi-rpc/.venv/bin/python
88

99
# Unit test files (no external dependencies)
10-
UNIT_TESTS := test/wire.test.ts test/describe.test.ts test/schema.test.ts test/output-collector.test.ts test/http/handler.test.ts test/http/token.test.ts test/http/sticky.test.ts test/auth.test.ts test/http-auth.test.ts test/bearer.test.ts
10+
UNIT_TESTS := test/wire.test.ts test/describe.test.ts test/schema.test.ts test/output-collector.test.ts test/http/handler.test.ts test/http/landing.test.ts test/http/oauth-pkce.test.ts test/http/token.test.ts test/http/sticky.test.ts test/auth.test.ts test/http-auth.test.ts test/bearer.test.ts
1111

1212
SRC := $(wildcard src/**/*.ts src/*.ts)
1313

conformance/describe.schema.json

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://query.farm/vgi/describe.schema.json",
4+
"title": "VGI worker describe.json (landing contract)",
5+
"description": "Validates GET {prefix}/describe.json. See docs/http-landing-contract.md.",
6+
"type": "object",
7+
"additionalProperties": false,
8+
"required": ["landing_schema_version", "worker", "server_id", "oauth", "cupola_base", "catalogs"],
9+
"properties": {
10+
"landing_schema_version": { "type": "integer", "minimum": 1 },
11+
"worker": {
12+
"type": "object",
13+
"additionalProperties": false,
14+
"required": ["name", "doc", "version", "lang"],
15+
"properties": {
16+
"name": { "type": "string" },
17+
"doc": { "type": "string" },
18+
"version": { "type": "string" },
19+
"lang": { "enum": ["python", "go", "rust", "typescript", "java"] }
20+
}
21+
},
22+
"server_id": { "type": "string" },
23+
"oauth": { "type": "boolean" },
24+
"cupola_base": { "type": "string", "format": "uri" },
25+
"catalogs": { "type": "array", "items": { "$ref": "#/$defs/catalog" } }
26+
},
27+
"$defs": {
28+
"catalog": {
29+
"type": "object",
30+
"additionalProperties": false,
31+
"required": ["name", "implementation_version", "data_version_spec", "data_versions", "attach_options", "tags", "counts", "schemas"],
32+
"properties": {
33+
"name": { "type": "string" },
34+
"implementation_version": { "type": ["string", "null"] },
35+
"data_version_spec": { "type": ["string", "null"] },
36+
"data_versions": { "type": "array", "items": { "$ref": "#/$defs/dataVersion" } },
37+
"attach_options": { "type": "array", "items": { "$ref": "#/$defs/attachOption" } },
38+
"tags": { "$ref": "#/$defs/tags" },
39+
"counts": { "$ref": "#/$defs/counts" },
40+
"schemas": { "type": "array", "items": { "$ref": "#/$defs/schema" } }
41+
}
42+
},
43+
"dataVersion": {
44+
"type": "object",
45+
"additionalProperties": false,
46+
"required": ["spec"],
47+
"properties": {
48+
"spec": { "type": "string" },
49+
"label": { "type": "string" }
50+
}
51+
},
52+
"attachOption": {
53+
"type": "object",
54+
"additionalProperties": false,
55+
"required": ["name", "type", "default", "description"],
56+
"properties": {
57+
"name": { "type": "string" },
58+
"type": { "type": "string" },
59+
"default": { "type": "string" },
60+
"description": { "type": "string" }
61+
}
62+
},
63+
"tags": {
64+
"type": "object",
65+
"additionalProperties": false,
66+
"properties": {
67+
"title": { "type": "string" },
68+
"doc_md": { "type": "string" },
69+
"source_url": { "type": "string" },
70+
"license": { "type": "string" },
71+
"author": { "type": "string" },
72+
"copyright": { "type": "string" },
73+
"support_contact": { "type": "string" },
74+
"support_policy_url": { "type": "string" },
75+
"keywords": { "type": "array", "items": { "type": "string" } }
76+
}
77+
},
78+
"counts": {
79+
"type": "object",
80+
"additionalProperties": false,
81+
"required": ["schemas", "tables", "views", "functions"],
82+
"properties": {
83+
"schemas": { "type": "integer", "minimum": 0 },
84+
"tables": { "type": "integer", "minimum": 0 },
85+
"views": { "type": "integer", "minimum": 0 },
86+
"functions": { "type": "integer", "minimum": 0 }
87+
}
88+
},
89+
"schema": {
90+
"type": "object",
91+
"additionalProperties": false,
92+
"required": ["name", "tables", "views", "functions"],
93+
"properties": {
94+
"name": { "type": "string" },
95+
"tables": { "type": "array", "items": { "$ref": "#/$defs/table" } },
96+
"views": { "type": "array", "items": { "$ref": "#/$defs/view" } },
97+
"functions": { "type": "array", "items": { "$ref": "#/$defs/function" } }
98+
}
99+
},
100+
"table": {
101+
"type": "object",
102+
"additionalProperties": false,
103+
"required": ["name", "cols", "comment"],
104+
"properties": {
105+
"name": { "type": "string" },
106+
"cols": { "type": "integer", "minimum": 0 },
107+
"comment": { "type": "string" }
108+
}
109+
},
110+
"view": {
111+
"type": "object",
112+
"additionalProperties": false,
113+
"required": ["name", "cols", "comment", "def"],
114+
"properties": {
115+
"name": { "type": "string" },
116+
"cols": { "type": "integer", "minimum": 0 },
117+
"comment": { "type": "string" },
118+
"def": { "type": "string" }
119+
}
120+
},
121+
"function": {
122+
"type": "object",
123+
"additionalProperties": false,
124+
"required": ["name", "type", "doc", "args"],
125+
"properties": {
126+
"name": { "type": "string" },
127+
"type": { "enum": ["scalar", "table", "aggregate", "table_in_out"] },
128+
"doc": { "type": "string" },
129+
"args": { "type": "array", "items": { "$ref": "#/$defs/arg" } },
130+
"returns": { "type": "string" }
131+
}
132+
},
133+
"arg": {
134+
"type": "object",
135+
"additionalProperties": false,
136+
"required": ["name", "type"],
137+
"properties": {
138+
"name": { "type": "string" },
139+
"type": { "type": "string" },
140+
"named": { "type": "boolean" },
141+
"default": { "type": "string" },
142+
"desc": { "type": "string" }
143+
}
144+
},
145+
"columns": {
146+
"type": "object",
147+
"additionalProperties": false,
148+
"required": ["columns"],
149+
"properties": {
150+
"columns": {
151+
"type": "array",
152+
"items": {
153+
"type": "object",
154+
"additionalProperties": false,
155+
"required": ["name", "type"],
156+
"properties": {
157+
"name": { "type": "string" },
158+
"type": { "type": "string" },
159+
"comment": { "type": "string" }
160+
}
161+
}
162+
}
163+
}
164+
}
165+
}
166+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
#!/usr/bin/env python3
2+
"""Landing-page conformance checker for VGI workers (all languages).
3+
4+
Drives a running worker's HTTP landing surface and asserts it conforms to the
5+
contract in ``docs/http-landing-contract.md``:
6+
7+
* ``GET {prefix}/describe.json`` validates against ``describe.schema.json`` and
8+
(optionally) equals a **normalized golden** — the cross-language equality guard.
9+
* ``GET {prefix}/`` serves the pinned shared ``landing.html`` (asset marker), and
10+
``?format=json`` returns a JSON status.
11+
* ``GET {prefix}/describe/{catalog}/{schema}/{table}.json`` returns valid columns.
12+
13+
Usage:
14+
run_landing_conformance.py --url http://localhost:8790 [--golden fixtures/describe.expected.json]
15+
run_landing_conformance.py --url http://localhost:8790 --golden fixtures/describe.expected.json --update
16+
17+
Exit code is non-zero on any failure. ``--update`` (re)writes the golden from the
18+
live worker (reviewers inspect the diff).
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import argparse
24+
import json
25+
import pathlib
26+
import sys
27+
import urllib.request
28+
29+
import jsonschema
30+
31+
_HERE = pathlib.Path(__file__).resolve().parent
32+
DEFAULT_SCHEMA = _HERE / "describe.schema.json"
33+
ASSET_MARKER = "vgi-landing-asset v"
34+
35+
# Fields dropped before comparing to a golden (volatile or language-specific).
36+
_VOLATILE_TOP = ("server_id", "worker", "oauth")
37+
38+
39+
def _get(url: str, accept: str = "application/json") -> tuple[int, bytes, str]:
40+
req = urllib.request.Request(url, headers={"Accept": accept})
41+
try:
42+
with urllib.request.urlopen(req) as resp: # noqa: S310 — trusted local URL
43+
return resp.status, resp.read(), resp.headers.get("Content-Type", "")
44+
except urllib.error.HTTPError as exc: # type: ignore[attr-defined]
45+
return exc.code, exc.read(), exc.headers.get("Content-Type", "")
46+
47+
48+
def normalize(doc: dict) -> dict:
49+
"""Strip volatile/language-specific fields so goldens compare across languages."""
50+
return {k: v for k, v in doc.items() if k not in _VOLATILE_TOP}
51+
52+
53+
def _canonical(doc: dict) -> str:
54+
return json.dumps(doc, indent=2, sort_keys=True, ensure_ascii=False)
55+
56+
57+
def check(
58+
base_url: str,
59+
*,
60+
schema_path: pathlib.Path = DEFAULT_SCHEMA,
61+
golden_path: pathlib.Path | None = None,
62+
) -> list[str]:
63+
"""Return a list of failure strings (empty == conformant)."""
64+
base = base_url.rstrip("/")
65+
fails: list[str] = []
66+
schema = json.loads(schema_path.read_text())
67+
columns_schema = {**schema, **schema["$defs"]["columns"]}
68+
69+
# 1) describe.json — schema-valid.
70+
status, body, _ = _get(f"{base}/describe.json")
71+
if status != 200:
72+
return [f"GET /describe.json returned HTTP {status}"]
73+
doc = json.loads(body)
74+
try:
75+
jsonschema.validate(doc, schema)
76+
except jsonschema.ValidationError as exc:
77+
fails.append(f"describe.json schema violation: {exc.message} at {list(exc.absolute_path)}")
78+
79+
# 2) GET / serves the pinned shared page; ?format=json is a JSON status.
80+
status, html, ctype = _get(f"{base}/", accept="text/html")
81+
if status != 200 or "text/html" not in ctype:
82+
fails.append(f"GET / (Accept: text/html) returned HTTP {status} {ctype!r}")
83+
elif ASSET_MARKER not in html.decode("utf-8", "replace"):
84+
fails.append(f"GET / did not serve the pinned landing.html (missing {ASSET_MARKER!r} marker)")
85+
status, jbody, _ = _get(f"{base}/?format=json")
86+
try:
87+
assert json.loads(jbody).get("status") == "ok"
88+
except Exception: # noqa: BLE001
89+
fails.append("GET /?format=json did not return a JSON status object")
90+
91+
# 3) Lazy column endpoints — validate one table + one view per schema.
92+
for cat in doc.get("catalogs", []):
93+
for sch in cat.get("schemas", []):
94+
samples = (sch.get("tables") or [])[:1] + (sch.get("views") or [])[:1]
95+
for obj in samples:
96+
cstatus, cbody, _ = _get(f"{base}/describe/{cat['name']}/{sch['name']}/{obj['name']}.json")
97+
if cstatus != 200:
98+
fails.append(f"columns {cat['name']}/{sch['name']}/{obj['name']} -> HTTP {cstatus}")
99+
continue
100+
try:
101+
jsonschema.validate(json.loads(cbody), columns_schema)
102+
except jsonschema.ValidationError as exc:
103+
fails.append(f"columns {sch['name']}.{obj['name']} schema violation: {exc.message}")
104+
105+
# 4) Golden diff (cross-language equality).
106+
if golden_path is not None:
107+
got = _canonical(normalize(doc))
108+
want = golden_path.read_text() if golden_path.exists() else None
109+
if want is None:
110+
fails.append(f"golden {golden_path} does not exist (run with --update)")
111+
elif got.strip() != want.strip():
112+
fails.append(f"describe.json does not match golden {golden_path.name} (normalized diff)")
113+
114+
return fails
115+
116+
117+
def main() -> int:
118+
ap = argparse.ArgumentParser(description=__doc__)
119+
ap.add_argument("--url", required=True, help="Worker base URL, e.g. http://localhost:8790")
120+
ap.add_argument("--schema", type=pathlib.Path, default=DEFAULT_SCHEMA)
121+
ap.add_argument("--golden", type=pathlib.Path, default=None)
122+
ap.add_argument("--update", action="store_true", help="Write the normalized golden from the live worker")
123+
args = ap.parse_args()
124+
125+
if args.update:
126+
if args.golden is None:
127+
ap.error("--update requires --golden")
128+
_, body, _ = _get(f"{args.url.rstrip('/')}/describe.json")
129+
args.golden.parent.mkdir(parents=True, exist_ok=True)
130+
args.golden.write_text(_canonical(normalize(json.loads(body))) + "\n")
131+
print(f"wrote golden {args.golden}")
132+
return 0
133+
134+
fails = check(args.url, schema_path=args.schema, golden_path=args.golden)
135+
if fails:
136+
print(f"FAIL ({len(fails)}):")
137+
for f in fails:
138+
print(f" - {f}")
139+
return 1
140+
print("PASS: landing conformance OK")
141+
return 0
142+
143+
144+
if __name__ == "__main__":
145+
sys.exit(main())

dist/http/handler.d.ts.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/http/index.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@ export type { CertValidateFn, XfccElement, XfccValidateFn } from "./mtls.js";
1010
export { mtlsAuthenticate, mtlsAuthenticateFingerprint, mtlsAuthenticateSubject, mtlsAuthenticateXfcc, parseXfcc, } from "./mtls.js";
1111
export { cookieAuthenticate } from "./oauth-pkce.js";
1212
export { type UnpackedToken, unpackStateToken } from "./token.js";
13-
export type { HttpHandlerOptions, StateSerializer } from "./types.js";
13+
export type { HttpHandlerOptions, LandingDescribeProvider, StateSerializer } from "./types.js";
1414
export { jsonStateSerializer } from "./types.js";
1515
//# sourceMappingURL=index.d.ts.map

dist/http/index.d.ts.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)