From a3d98a209ad72209ff30706bfae1a1fb1ab1531c Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:53:02 +0400 Subject: [PATCH] Remove the unused TypeScript reader package @policyengine/microcosm-provider-orrery has no consumer: PolicyEngine/microcosm-runs keeps its own publication reader. Delete typescript/orrery, the root package.json and the reader check and publish scripts, and drop the reader CI job and npm publish job. Releases now version only pyproject.toml and uv.lock and publish only the microcosm-emitter Python distribution to PyPI. The workflow tests pin the remaining job sets, and the diverged-version test becomes a check that the release refuses a missing or duplicated version line. Fixes #15 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 14 -- .github/workflows/push.yaml | 23 +-- .gitignore | 3 - README.md | 13 +- .../remove-typescript-reader.removed.md | 1 + package.json | 15 -- tests/test_workflows.py | 75 ++++---- tools/bump-version.py | 42 ++--- tools/check-changelog.sh | 2 +- tools/check-reader-installed.ts | 65 ------- tools/check-reader.sh | 11 -- tools/publish-reader.sh | 11 -- tools/update-version.sh | 6 +- typescript/orrery/README.md | 13 -- typescript/orrery/bun.lock | 88 ---------- typescript/orrery/package.json | 46 ----- typescript/orrery/src/browser.ts | 98 ----------- typescript/orrery/src/contract.ts | 130 -------------- typescript/orrery/src/server.ts | 52 ------ typescript/orrery/tests/contract.test.ts | 43 ----- typescript/orrery/tests/reader.test.ts | 160 ------------------ typescript/orrery/tsconfig.build.json | 11 -- typescript/orrery/tsconfig.json | 13 -- 23 files changed, 68 insertions(+), 867 deletions(-) create mode 100644 changelog.d/remove-typescript-reader.removed.md delete mode 100644 package.json delete mode 100644 tools/check-reader-installed.ts delete mode 100644 tools/check-reader.sh delete mode 100644 tools/publish-reader.sh delete mode 100644 typescript/orrery/README.md delete mode 100644 typescript/orrery/bun.lock delete mode 100644 typescript/orrery/package.json delete mode 100644 typescript/orrery/src/browser.ts delete mode 100644 typescript/orrery/src/contract.ts delete mode 100644 typescript/orrery/src/server.ts delete mode 100644 typescript/orrery/tests/contract.test.ts delete mode 100644 typescript/orrery/tests/reader.test.ts delete mode 100644 typescript/orrery/tsconfig.build.json delete mode 100644 typescript/orrery/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4accd02..920345e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,17 +83,3 @@ jobs: name: distributions path: dist - run: bash tools/check-collector.sh - - reader: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 - with: - bun-version: "1.4.2" - - run: bash tools/check-reader.sh - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: orrery-reader - path: reader-dist/*.tgz - if-no-files-found: error diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index 24a857b..e41d87c 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -13,8 +13,8 @@ permissions: # run, and the app may bypass the main ruleset. On that release commit, the # remaining jobs verify, tag, then publish, each only after the one before. # -# PyPI and npm trust this file by name, in the pypi environment, which only -# main can use. Renaming it stops publication. +# PyPI trusts this file by name, in the pypi environment, which only main can +# use. Renaming it stops publication. jobs: versioning: name: Update versioning @@ -76,22 +76,3 @@ jobs: - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 with: packages-dir: dist/ - - publish-reader: - needs: [verify, publish] - runs-on: ubuntu-latest - environment: pypi - permissions: - contents: read - id-token: write - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: "24" - registry-url: "https://registry.npmjs.org" - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: orrery-reader - path: reader-dist - - run: bash tools/publish-reader.sh diff --git a/.gitignore b/.gitignore index e598fa6..5c2ae59 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,3 @@ dist/ .coverage .collector/ .test-artifacts/ -node_modules/ -reader-dist/ -typescript/orrery/dist/ diff --git a/README.md b/README.md index 44ed9da..f126750 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,6 @@ The host can also start telemetry and graph modules together with `start_service Preserve the selected source files until publication succeeds. `microcosm-emitter-publish-graph` asks the service to retry a publication by ID and can re-enqueue a preserved directory on another machine. It never rewrites the original build receipt; the queue and separate latest-status file record retries. -The existing TypeScript package `@policyengine/microcosm-provider-orrery` reads immutable published graph files on a server or in a browser. It remains separate from the Python distribution and does not change the hosted telemetry API. - ## Development Use Python 3.13 or 3.14 on Linux or macOS. @@ -46,23 +44,22 @@ uv sync --locked bash tools/check-quality.sh uv run --no-sync pytest -m 'not collector' bash tools/check-artifacts.sh -bash tools/check-reader.sh bash tools/check-changelog.sh ``` -Tests isolate ambient credentials and use synthetic identities. The artifact check builds one source archive and wheel, installs that wheel into a clean environment away from the source checkout, verifies import boundaries, and exercises telemetry-only, graph-only and combined service startup with packaged migrations. Reader checks cover tests, formatting, types, build and an isolated tarball installation; they do not inspect visual styling. +Tests isolate ambient credentials and use synthetic identities. The artifact check builds one source archive and wheel, installs that wheel into a clean environment away from the source checkout, verifies import boundaries, and exercises telemetry-only, graph-only and combined service startup with packaged migrations. The collector compatibility job tests the installed wheel against a pinned dashboard collector and disposable PostgreSQL. It covers the HTTP exchange, event ingestion, blocked outcomes and supplied failure codes in dashboard run documents, rejected identities, and duplicate delivery after a lost acknowledgement. Local execution requires that pinned collector checkout and a loopback test database; `tools/check-collector.sh` verifies these inputs. Never point this test at a deployed database. The pin verifies source compatibility, not which revision the hosted collector currently runs. ## Publication -Every merge to `main` releases the Python distribution `microcosm-emitter` to PyPI and the TypeScript reader `@policyengine/microcosm-provider-orrery` to npm at one shared version. Each pull request adds a changelog fragment named `changelog.d/..md`, where the type is `breaking`, `added`, `changed`, `fixed` or `removed`; `tools/check-changelog.sh` refuses a pull request without one. +Every merge to `main` releases the Python distribution `microcosm-emitter` to PyPI. Each pull request adds a changelog fragment named `changelog.d/..md`, where the type is `breaking`, `added`, `changed`, `fixed` or `removed`; `tools/check-changelog.sh` refuses a pull request without one. `push.yaml` runs twice for each merge: -1. On the merge commit, it bumps the version in `pyproject.toml`, both `package.json` files and `uv.lock`: a major bump for a breaking fragment, a minor bump for an added or removed fragment, and a patch bump otherwise. It collects the fragments into `CHANGELOG.md` and commits the result as "Update microcosm-emitter" using the PolicyEngine GitHub App, which may bypass the `main` ruleset. -2. On that commit, it runs every CI check, tags the commit with the bare version (for example `0.2.0`), publishes the tested wheel and source archive to PyPI, and then publishes the tested reader tarball to npm. Each step starts only after the one before succeeds. +1. On the merge commit, it bumps the version in `pyproject.toml` and `uv.lock`: a major bump for a breaking fragment, a minor bump for an added or removed fragment, and a patch bump otherwise. It collects the fragments into `CHANGELOG.md` and commits the result as "Update microcosm-emitter" using the PolicyEngine GitHub App, which may bypass the `main` ruleset. +2. On that commit, it runs every CI check, tags the commit with the bare version (for example `0.2.0`), and publishes the tested wheel and source archive to PyPI. Each step starts only after the one before succeeds. -Both registries accept uploads only through trusted publishing from `push.yaml` in the `pypi` environment, which only `main` can use. CI holds no long-lived registry token. Renaming the workflow or the environment stops publication until both registries' trusted-publisher settings change to match. +PyPI accepts uploads only through trusted publishing from `push.yaml` in the `pypi` environment, which only `main` can use. CI holds no long-lived registry token. Renaming the workflow or the environment stops publication until PyPI's trusted-publisher settings change to match. No hosted-service deployment or consumer activation follows from publishing this package. diff --git a/changelog.d/remove-typescript-reader.removed.md b/changelog.d/remove-typescript-reader.removed.md new file mode 100644 index 0000000..a9661e6 --- /dev/null +++ b/changelog.d/remove-typescript-reader.removed.md @@ -0,0 +1 @@ +Remove the unused TypeScript reader `@policyengine/microcosm-provider-orrery`, so each release now publishes only the `microcosm-emitter` Python distribution to PyPI. diff --git a/package.json b/package.json deleted file mode 100644 index ddb5d92..0000000 --- a/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "@policyengine/microcosm-provider-orrery", - "version": "0.2.1", - "private": true, - "type": "module", - "description": "Pinned-source development entry points; publish the built package from typescript/orrery", - "exports": { - "./contract": "./typescript/orrery/src/contract.ts", - "./browser": "./typescript/orrery/src/browser.ts", - "./server": "./typescript/orrery/src/server.ts" - }, - "peerDependencies": { - "@axiom-foundation/orrery": "0.6.1" - } -} diff --git a/tests/test_workflows.py b/tests/test_workflows.py index d475788..30a70dc 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -10,7 +10,7 @@ ROOT = Path(__file__).resolve().parents[1] RELEASE_COMMIT = "Update microcosm-emitter" -VERSIONED_FILES = ("pyproject.toml", "package.json", "typescript/orrery/package.json") +VERSIONED_FILE = "pyproject.toml" def workflow_paths(): @@ -30,9 +30,7 @@ def bump_version(): def release_tree(root, *fragments): - for name in VERSIONED_FILES: - (root / name).parent.mkdir(parents=True, exist_ok=True) - shutil.copy(ROOT / name, root / name) + shutil.copy(ROOT / VERSIONED_FILE, root / VERSIONED_FILE) (root / "changelog.d").mkdir() (root / "changelog.d/.gitkeep").touch() for fragment in fragments: @@ -40,6 +38,16 @@ def release_tree(root, *fragments): return root +def remove_version_line(pyproject_text): + return re.sub( + r'^version = "[^"]+"\n', "", pyproject_text, count=1, flags=re.MULTILINE + ) + + +def add_tool_version_line(pyproject_text): + return pyproject_text + '\n[tool.example]\nversion = "9.9.9"\n' + + def test_shell_logic_lives_in_scripts(): for path in workflow_paths(): config = workflow(path.name) @@ -65,7 +73,7 @@ def test_verification_includes_real_collector_and_matrix(): config = workflow("ci.yml") assert set(config["on"]) == {"workflow_call"} jobs = config["jobs"] - assert set(jobs) == {"quality", "tests", "build-packages", "collector", "reader"} + assert set(jobs) == {"quality", "tests", "build-packages", "collector"} assert jobs["tests"]["strategy"]["matrix"] == { "os": ["ubuntu-latest", "macos-latest"], "python": ["3.13", "3.14"], @@ -78,6 +86,7 @@ def test_release_commit_is_verified_then_tagged_then_published(): config = workflow("push.yaml") assert config["on"]["push"]["branches"] == ["main"] jobs = config["jobs"] + assert set(jobs) == {"versioning", "verify", "tag", "publish"} assert f"!= '{RELEASE_COMMIT}'" in jobs["versioning"]["if"] assert jobs["versioning"]["steps"][-2]["run"] == "bash tools/update-version.sh" assert jobs["versioning"]["steps"][-1]["with"]["message"] == RELEASE_COMMIT @@ -86,7 +95,6 @@ def test_release_commit_is_verified_then_tagged_then_published(): assert jobs["tag"]["needs"] == "verify" assert jobs["tag"]["steps"][-1]["run"] == "bash tools/publish-git-tag.sh" assert jobs["publish"]["needs"] == "tag" - assert jobs["publish-reader"]["needs"] == ["verify", "publish"] def test_only_publication_holds_oidc_and_only_versioning_holds_secrets(): @@ -96,29 +104,18 @@ def test_only_publication_holds_oidc_and_only_versioning_holds_secrets(): oidc_jobs = { name for name, job in jobs.items() if "id-token" in job.get("permissions", {}) } - assert oidc_jobs == {"publish", "publish-reader"} - for name in oidc_jobs: - assert jobs[name]["environment"] == "pypi" + assert oidc_jobs == {"publish"} + assert jobs["publish"]["environment"] == "pypi" assert jobs["publish"]["permissions"] == {"id-token": "write"} text = (ROOT / ".github/workflows/push.yaml").read_text() assert set(re.findall(r"secrets\.(\w+)", text)) == {"APP_ID", "APP_PRIVATE_KEY"} assert text.count("secrets.") == 2 -def test_registries_receive_only_verified_artifacts(): - jobs = workflow("push.yaml")["jobs"] - publish = jobs["publish"] +def test_pypi_receives_only_verified_artifacts(): + publish = workflow("push.yaml")["jobs"]["publish"] assert publish["steps"][0]["with"]["name"] == "distributions" assert not any("run" in step for step in publish["steps"]) - reader = jobs["publish-reader"] - download = next( - step for step in reader["steps"] if "download-artifact@" in step.get("uses", "") - ) - assert download["with"]["name"] == "orrery-reader" - assert reader["steps"][-1]["run"] == "bash tools/publish-reader.sh" - publish = (ROOT / "tools/publish-reader.sh").read_text() - # A bare "reader-dist/" is a GitHub shorthand to npm, not a path. - assert 'npm publish --access public --provenance "./${archives[0]}"' in publish @pytest.mark.parametrize( @@ -135,20 +132,21 @@ def test_fragment_types_choose_the_bump(bump_version, types, expected): assert bump_version["next_version"]("1.4.2", types) == expected -def test_release_writes_one_version_to_every_package(tmp_path, bump_version): +def test_release_rewrites_only_the_version_line(tmp_path, bump_version): root = release_tree(tmp_path, "1.added.md", "repair.fixed.md") current, upcoming = bump_version["plan_release"](root) assert upcoming == bump_version["next_version"](current, {"added", "fixed"}) bump_version["write_version"](root, current, upcoming) assert bump_version["current_version"](root) == upcoming - for name in VERSIONED_FILES: - original = (ROOT / name).read_text().splitlines() - updated = (root / name).read_text().splitlines() - assert len(updated) == len(original) - changed = [ - pair for pair in zip(original, updated, strict=True) if pair[0] != pair[1] - ] - assert changed == [(changed[0][0], changed[0][0].replace(current, upcoming))] + original = (ROOT / VERSIONED_FILE).read_text().splitlines() + updated = (root / VERSIONED_FILE).read_text().splitlines() + assert len(updated) == len(original) + changed = [ + (original_line, updated_line) + for original_line, updated_line in zip(original, updated, strict=True) + if original_line != updated_line + ] + assert changed == [(f'version = "{current}"', f'version = "{upcoming}"')] @pytest.mark.parametrize( @@ -162,13 +160,18 @@ def test_release_refuses_missing_or_untyped_fragments( bump_version["plan_release"](root) -def test_release_refuses_diverged_package_versions(tmp_path, bump_version): +@pytest.mark.parametrize( + "edit_pyproject", + [remove_version_line, add_tool_version_line], + ids=["missing", "duplicated"], +) +def test_release_refuses_a_missing_or_duplicated_version_line( + tmp_path, bump_version, edit_pyproject +): root = release_tree(tmp_path, "1.fixed.md") - reader = root / "typescript/orrery/package.json" - reader.write_text( - re.sub(r'"version": "[^"]+"', '"version": "9.9.9"', reader.read_text(), count=1) - ) - with pytest.raises(ValueError, match="one shared version"): + pyproject = root / VERSIONED_FILE + pyproject.write_text(edit_pyproject(pyproject.read_text())) + with pytest.raises(ValueError, match="one version line"): bump_version["plan_release"](root) diff --git a/tools/bump-version.py b/tools/bump-version.py index 993ea51..d31a7c7 100644 --- a/tools/bump-version.py +++ b/tools/bump-version.py @@ -1,8 +1,8 @@ -"""Choose the next shared release version from pending changelog fragments. +"""Choose the next release version from pending changelog fragments. A breaking fragment bumps the major version, an added or removed fragment the -minor version, and any other fragment the patch version. The Python -distribution and both reader package manifests always share one version. +minor version, and any other fragment the patch version. The version is read +from and written to the single version line in pyproject.toml. Prints the next version. With --check, writes nothing. """ @@ -17,13 +17,8 @@ "Name each fragment changelog.d/..md, where is one of: " + ", ".join(FRAGMENT_TYPES) ) +VERSIONED_FILE = "pyproject.toml" PYPROJECT_VERSION = re.compile(r'^version = "(\d+\.\d+\.\d+)"$', re.MULTILINE) -PACKAGE_VERSION = re.compile(r'^ "version": "(\d+\.\d+\.\d+)",$', re.MULTILINE) -VERSIONED_FILES = { - "pyproject.toml": PYPROJECT_VERSION, - "package.json": PACKAGE_VERSION, - "typescript/orrery/package.json": PACKAGE_VERSION, -} def fragment_types(changelog_dir: Path) -> set[str]: @@ -57,14 +52,12 @@ def next_version(version: str, types: set[str]) -> str: def current_version(root: Path) -> str: - found = { - name: pattern.findall((root / name).read_text()) - for name, pattern in VERSIONED_FILES.items() - } - versions = {version for matches in found.values() for version in matches} - if len(versions) != 1 or any(len(matches) != 1 for matches in found.values()): - raise ValueError(f"Expected one shared version, found {found}.") - return versions.pop() + versions = PYPROJECT_VERSION.findall((root / VERSIONED_FILE).read_text()) + if len(versions) != 1: + raise ValueError( + f"Expected one version line in {VERSIONED_FILE}, found {versions}." + ) + return versions[0] def plan_release(root: Path) -> tuple[str, str]: @@ -73,15 +66,14 @@ def plan_release(root: Path) -> tuple[str, str]: def write_version(root: Path, current: str, upcoming: str) -> None: - for name, pattern in VERSIONED_FILES.items(): - path = root / name - path.write_text( - pattern.sub( - lambda match: match.group(0).replace(current, upcoming), - path.read_text(), - count=1, - ) + path = root / VERSIONED_FILE + path.write_text( + PYPROJECT_VERSION.sub( + lambda match: match.group(0).replace(current, upcoming), + path.read_text(), + count=1, ) + ) def main(arguments: list[str]) -> None: diff --git a/tools/check-changelog.sh b/tools/check-changelog.sh index 0b9636e..29ab417 100644 --- a/tools/check-changelog.sh +++ b/tools/check-changelog.sh @@ -2,5 +2,5 @@ set -euo pipefail # Refuse a pull request whose merge could not be released: changelog.d must hold -# at least one typed fragment, and all package manifests must share one version. +# at least one typed fragment, and pyproject.toml exactly one version line. uv run --no-project python tools/bump-version.py --check diff --git a/tools/check-reader-installed.ts b/tools/check-reader-installed.ts deleted file mode 100644 index 6529608..0000000 --- a/tools/check-reader-installed.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { mkdtempSync, writeFileSync, readdirSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; - -const files = readdirSync("reader-dist").filter((name) => - name.endsWith(".tgz"), -); -if (files.length !== 1) throw new Error("Expected exactly one reader tarball."); -const workspace = mkdtempSync(join(tmpdir(), "provider-reader-")); -function run(args: string[]) { - const result = Bun.spawnSync(args, { - cwd: workspace, - stdout: "inherit", - stderr: "inherit", - }); - if (result.exitCode !== 0) throw new Error("Installed reader check failed."); -} -try { - writeFileSync( - join(workspace, "package.json"), - JSON.stringify({ - private: true, - type: "module", - dependencies: { - "@policyengine/microcosm-provider-orrery": resolve( - "reader-dist", - files[0], - ), - "@axiom-foundation/orrery": "0.6.1", - }, - }), - ); - run(["bun", "install", "--ignore-scripts"]); - writeFileSync( - join(workspace, "check.ts"), - ` -import { loadDocument } from "@policyengine/microcosm-provider-orrery/browser"; -import { createPublicationReader } from "@policyengine/microcosm-provider-orrery/server"; -const reader = createPublicationReader({read: async () => null}); -if (await reader.read("missing") !== null) throw new Error("Expected missing publication."); -try { - await loadDocument("missing", new AbortController().signal, async () => new Response("", {status: 404})); - throw new Error("Missing publication was accepted."); -} catch (error) { - if (!(error instanceof Error) || !error.message.includes("not found")) throw error; -} -`, - ); - run(["bun", "check.ts"]); - writeFileSync( - join(workspace, "browser.ts"), - 'export { loadDocument } from "@policyengine/microcosm-provider-orrery/browser";', - ); - run([ - "bun", - "build", - "browser.ts", - "--target", - "browser", - "--outfile", - "browser.js", - ]); -} finally { - rmSync(workspace, { recursive: true, force: true }); -} diff --git a/tools/check-reader.sh b/tools/check-reader.sh deleted file mode 100644 index c438509..0000000 --- a/tools/check-reader.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -cd typescript/orrery -bun install --frozen-lockfile -bun run format:check -bun run typecheck -bun test -bun run build -bun pm pack --destination ../../reader-dist -cd ../.. -bun tools/check-reader-installed.ts diff --git a/tools/publish-reader.sh b/tools/publish-reader.sh deleted file mode 100644 index 97467a6..0000000 --- a/tools/publish-reader.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -node -e 'const [major, minor, patch] = require("child_process").execFileSync("npm", ["--version"], {encoding:"utf8"}).trim().split(".").map(Number); if (major < 11 || (major === 11 && (minor < 5 || (minor === 5 && patch < 1)))) throw new Error("npm OIDC requires npm >=11.5.1");' -shopt -s nullglob -archives=(reader-dist/*.tgz) -if (("${#archives[@]}" != 1)); then - echo "Expected exactly one verified reader tarball." >&2 - exit 1 -fi -# Without "./", npm reads "reader-dist/" as a GitHub owner/repo shorthand. -npm publish --access public --provenance "./${archives[0]}" diff --git a/tools/update-version.sh b/tools/update-version.sh index 0abfd63..f73adfe 100644 --- a/tools/update-version.sh +++ b/tools/update-version.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash set -euo pipefail -# Release the Python distribution and the reader at one version: bump both from -# the pending fragment types, collect the fragments into CHANGELOG.md, and -# record the emitter's own new version in uv.lock for `uv sync --locked`. +# Release the Python distribution: bump pyproject.toml from the pending fragment +# types, collect the fragments into CHANGELOG.md, and record the emitter's own +# new version in uv.lock for `uv sync --locked`. version="$(uv run --no-project python tools/bump-version.py)" uvx --from "towncrier==26.9.0" towncrier build --yes --version "$version" uv lock diff --git a/typescript/orrery/README.md b/typescript/orrery/README.md deleted file mode 100644 index e7830bc..0000000 --- a/typescript/orrery/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Orrery publication reads - -The package owns publication validation, server-side metadata/evidence reads and -browser-side bounded downloads, SHA-256 verification and Orrery parsing. -It does not contain React components, routes, storage credentials or a hosted API. - -Import `createPublicationReader` from the `/server` entry point with a storage -adapter. Import `loadDocument` from `/browser` in the runs app. Browser exports -never import Node cryptography or server credentials. Existing publication URLs -and the Orrery React component remain unchanged. - -Build and test with Bun. Package publication and consumer activation are separate -operations; do not activate consumers before verifying the published tarball. diff --git a/typescript/orrery/bun.lock b/typescript/orrery/bun.lock deleted file mode 100644 index 58fb6c7..0000000 --- a/typescript/orrery/bun.lock +++ /dev/null @@ -1,88 +0,0 @@ -{ - "lockfileVersion": 2, - "configVersion": 1, - "workspaces": { - "": { - "name": "@policyengine/microcosm-provider-orrery", - "devDependencies": { - "@axiom-foundation/orrery": "0.6.1", - "@types/bun": "^1.3.0", - "@types/node": "^24.0.0", - "prettier": "^3.6.0", - "typescript": "^5.9.0", - }, - "peerDependencies": { - "@axiom-foundation/orrery": "0.6.1", - }, - }, - }, - "packages": { - "@axiom-foundation/orrery": ["@axiom-foundation/orrery@0.6.1", "", { "dependencies": { "@dagrejs/dagre": "3.1.1", "@xyflow/react": "12.11.6" }, "peerDependencies": { "react": ">=18 <20", "react-dom": ">=18 <20" }, "bin": { "orrery": "dist/cli.js", "graph-explorer": "dist/cli.js" } }, "sha512-VHYAuBZ50KhSXou1ogGG5IBjG1ET3Mnr05rIwUtoUgmgFu7eg5iH9rlsLQ28KQI/Lkd6yBsdYUsE3wo7kyCkhA=="], - - "@dagrejs/dagre": ["@dagrejs/dagre@3.1.1", "", { "dependencies": { "@dagrejs/graphlib": "4.0.5" } }, "sha512-zroZB1dFOFiGgv4Xcrn1DckB1o4aOikPqD2NDQPV0WM//CXGcS6xiD0rNkqHmw6FEg4tabt4nxPLwgCWT+Vb2A=="], - - "@dagrejs/graphlib": ["@dagrejs/graphlib@4.0.5", "", {}, "sha512-7xrBTqIts3o+PMUZX97wSc+7TUbW+/rULzGNCTP6yooNVDXbzw4Wutg/H/xOutTB/c/k0YqOAavgPh4/Zk9PFA=="], - - "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], - - "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], - - "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], - - "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], - - "@types/d3-selection": ["@types/d3-selection@3.0.12", "", {}, "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw=="], - - "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], - - "@types/d3-zoom": ["@types/d3-zoom@3.0.9", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA=="], - - "@types/node": ["@types/node@24.19.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ=="], - - "@xyflow/react": ["@xyflow/react@12.11.6", "", { "dependencies": { "@xyflow/system": "0.0.82", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "@types/react": ">=17", "@types/react-dom": ">=17", "react": ">=17", "react-dom": ">=17" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9XsEJNHjatKYndszKTF/bsU7FOP9dJ6V/EQwzy3oMdtqgBuUq7BjKSwkEo+C7s4qHstHQfwwoHA3E8QfpPxZZQ=="], - - "@xyflow/system": ["@xyflow/system@0.0.82", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-4DKnL3CGtCGLRSmgDqaajRVgeksMXq/Yw4wPfdMfm7JvdIiWHGzVYOfFUztiATwdBXZqUU6HhehwUdbV9G23PQ=="], - - "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], - - "classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="], - - "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], - - "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], - - "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], - - "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], - - "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], - - "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], - - "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], - - "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], - - "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], - - "prettier": ["prettier@3.9.9", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg=="], - - "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], - - "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], - - "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - - "use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="], - - "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], - - "bun-types/@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="], - - "bun-types/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], - } -} diff --git a/typescript/orrery/package.json b/typescript/orrery/package.json deleted file mode 100644 index 05fbea2..0000000 --- a/typescript/orrery/package.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "@policyengine/microcosm-provider-orrery", - "version": "0.2.1", - "description": "Validated immutable publication reads for Orrery applications", - "type": "module", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/PolicyEngine/microcosm-emitter.git", - "directory": "typescript/orrery" - }, - "files": [ - "dist", - "README.md" - ], - "exports": { - "./contract": { - "types": "./dist/contract.d.ts", - "import": "./dist/contract.js" - }, - "./browser": { - "types": "./dist/browser.d.ts", - "import": "./dist/browser.js" - }, - "./server": { - "types": "./dist/server.d.ts", - "import": "./dist/server.js" - } - }, - "scripts": { - "build": "tsc -p tsconfig.build.json", - "typecheck": "tsc --noEmit", - "test": "bun test", - "format:check": "prettier --check src tests *.json README.md" - }, - "peerDependencies": { - "@axiom-foundation/orrery": "0.6.1" - }, - "devDependencies": { - "@axiom-foundation/orrery": "0.6.1", - "@types/bun": "^1.3.0", - "@types/node": "^24.0.0", - "prettier": "^3.6.0", - "typescript": "^5.9.0" - } -} diff --git a/typescript/orrery/src/browser.ts b/typescript/orrery/src/browser.ts deleted file mode 100644 index a42e98d..0000000 --- a/typescript/orrery/src/browser.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { parseGraphDocument } from "@axiom-foundation/orrery"; -import { - inventory, - publication, - publicationId, - MAX_FILE_BYTES, -} from "./contract.js"; - -export type Fetcher = ( - input: string | URL | Request, - init?: RequestInit, -) => Promise; - -async function boundedBytes( - response: Response, - maximum: number, -): Promise { - if (!response.body) throw new Error("The stored graph is unavailable."); - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let size = 0; - try { - while (true) { - const { value, done } = await reader.read(); - if (done) break; - size += value.byteLength; - if (size > maximum) - throw new Error("Response exceeds its publication size limit."); - chunks.push(value); - } - } finally { - await reader.cancel(); - } - const result = new Uint8Array(size); - let offset = 0; - for (const chunk of chunks) { - result.set(chunk, offset); - offset += chunk.byteLength; - } - return result; -} -async function digest(bytes: Uint8Array): Promise { - return Array.from( - new Uint8Array( - await crypto.subtle.digest("SHA-256", bytes as Uint8Array), - ), - (value) => value.toString(16).padStart(2, "0"), - ).join(""); -} -export async function loadDocument( - id: string, - signal: AbortSignal, - fetcher: Fetcher = fetch, -) { - publicationId(id); - const response = await fetcher(`/api/runs/${encodeURIComponent(id)}/`, { - signal, - redirect: "error", - credentials: "omit", - }); - if (response.status === 404) - throw new Error("This graph publication was not found."); - if (!response.ok) - throw new Error("Graph publication metadata is unavailable."); - const bytes = await boundedBytes(response, 256 * 1024); - const saved = publication( - JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)), - id, - ); - if ( - (await digest( - new TextEncoder().encode(JSON.stringify(inventory(saved))), - )) !== saved.inventory_sha256 - ) - throw new Error("Inventory digest does not match its publication."); - const file = saved.files.find((file) => file.role === "graph")!; - const graph = await fetcher(file.url, { - signal, - redirect: "error", - credentials: "omit", - }); - if (!graph.ok) throw new Error("The stored graph is unavailable."); - const payload = await boundedBytes( - graph, - Math.min(file.bytes, MAX_FILE_BYTES), - ); - if (payload.length !== file.bytes) - throw new Error("Graph size does not match its publication."); - const checksum = await digest(payload); - if (checksum !== file.sha256) - throw new Error("Graph digest does not match its publication."); - return { - document: parseGraphDocument( - JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(payload)), - ), - digest: checksum, - }; -} diff --git a/typescript/orrery/src/contract.ts b/typescript/orrery/src/contract.ts deleted file mode 100644 index 2774419..0000000 --- a/typescript/orrery/src/contract.ts +++ /dev/null @@ -1,130 +0,0 @@ -export const MAX_FILE_BYTES = 64 * 1024 * 1024; -export const MAX_TOTAL_BYTES = 512 * 1024 * 1024; -export const MAX_FILES = 256; -export type FileRole = - | "graph" - | "schema" - | "index" - | "declaration" - | "manifest" - | "binding" - | "summaries" - | "upstream"; -export interface RunFile { - name: string; - role: FileRole; - bytes: number; - sha256: string; -} -export interface Inventory { - version: 1; - publication_id: string; - files: RunFile[]; -} -export interface Publication extends Inventory { - inventory_sha256: string; - published_at: string; - files: (RunFile & { url: string })[]; -} -export class ApiError extends Error { - constructor( - public status: number, - public code: string, - ) { - super(code); - } -} -export function publicationId(value: unknown): string { - if ( - typeof value !== "string" || - !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value) - ) - throw new ApiError(400, "invalid_publication_id"); - return value; -} -const roleNames: Record = { - graph: /^graph\.orrery\.json$/, - schema: /^graph\.schema\.json$/, - index: /^execution\.evidence\.json$/, - declaration: /^evidence-[a-zA-Z0-9_-]+-graph\.json$/, - manifest: /^evidence-[a-zA-Z0-9_-]+-manifest\.json$/, - binding: /^evidence-[a-zA-Z0-9_-]+-binding\.json$/, - summaries: /^evidence-[a-zA-Z0-9_-]+-summaries\.json$/, - upstream: /^upstream-[a-f0-9]{64}\.json$/, -}; -export function inventory(raw: unknown): Inventory { - if (!raw || typeof raw !== "object") - throw new ApiError(400, "invalid_inventory"); - const data = raw as Record; - const id = publicationId(data.publication_id); - if ( - data.version !== 1 || - !Array.isArray(data.files) || - data.files.length === 0 || - data.files.length > MAX_FILES - ) - throw new ApiError(400, "invalid_inventory"); - const names = new Set(); - let total = 0; - const files: RunFile[] = data.files.map((raw) => { - if (!raw || typeof raw !== "object") - throw new ApiError(400, "invalid_file"); - const f = raw as RunFile; - if ( - !Object.hasOwn(roleNames, f.role) || - typeof f.name !== "string" || - !roleNames[f.role].test(f.name) || - names.has(f.name) || - !Number.isSafeInteger(f.bytes) || - f.bytes <= 0 || - f.bytes > MAX_FILE_BYTES || - typeof f.sha256 !== "string" || - !/^[a-f0-9]{64}$/.test(f.sha256) - ) - throw new ApiError(400, "invalid_file"); - names.add(f.name); - total += f.bytes; - return { name: f.name, role: f.role, bytes: f.bytes, sha256: f.sha256 }; - }); - if ( - total > MAX_TOTAL_BYTES || - files.filter((f) => f.role === "graph").length !== 1 - ) - throw new ApiError(400, "invalid_inventory"); - return { - version: 1, - publication_id: id, - files: files.sort((a, b) => - a.name < b.name ? -1 : a.name > b.name ? 1 : 0, - ), - }; -} - -export function publication(raw: unknown, expectedId: string): Publication { - const value = inventory(raw); - const saved = raw as Publication; - if ( - value.publication_id !== expectedId || - !/^[a-f0-9]{64}$/.test(saved.inventory_sha256 ?? "") || - typeof saved.published_at !== "string" || - !Number.isFinite(Date.parse(saved.published_at)) - ) - throw new ApiError(400, "invalid_publication"); - const urls = new Map(saved.files.map((file) => [file.name, file.url])); - return { - ...value, - inventory_sha256: saved.inventory_sha256, - published_at: saved.published_at, - files: value.files.map((file) => { - const url = new URL(urls.get(file.name)!); - if ( - url.protocol !== "https:" || - !url.hostname.endsWith(".public.blob.vercel-storage.com") || - url.username || - url.password - ) - throw new ApiError(400, "invalid_storage_url"); - return { ...file, url: url.toString() }; - }), - }; -} diff --git a/typescript/orrery/src/server.ts b/typescript/orrery/src/server.ts deleted file mode 100644 index 3dff69c..0000000 --- a/typescript/orrery/src/server.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { createHash } from "node:crypto"; -import { - ApiError, - inventory, - publication, - publicationId, - type Inventory, -} from "./contract.js"; - -export function sha256(bytes: string | Uint8Array): string { - return createHash("sha256").update(bytes).digest("hex"); -} -export function inventoryDigest(value: Inventory): string { - return sha256(JSON.stringify(inventory(value))); -} -export function publicationPath(id: string): string { - return `publications/${publicationId(id)}.json`; -} -export function objectPath(value: Inventory, name: string): string { - return `objects/${value.publication_id}/${inventoryDigest(value)}/${name}`; -} -export interface PublicationStorage { - read( - path: string, - maximum: number, - ): Promise<{ bytes: Uint8Array; url: string } | null>; -} -export function createPublicationReader(storage: PublicationStorage) { - async function read(id: string) { - const object = await storage.read(publicationPath(id), 256 * 1024); - if (!object) return null; - if (object.bytes.byteLength > 256 * 1024) - throw new ApiError(400, "metadata_too_large"); - const value = publication( - JSON.parse( - new TextDecoder("utf-8", { fatal: true }).decode(object.bytes), - ), - id, - ); - if (inventoryDigest(value) !== value.inventory_sha256) - throw new ApiError(400, "inventory_digest_mismatch"); - return value; - } - return { - read, - async evidence(id: string, name: string) { - return ( - (await read(id))?.files.find((file) => file.name === name)?.url ?? null - ); - }, - }; -} diff --git a/typescript/orrery/tests/contract.test.ts b/typescript/orrery/tests/contract.test.ts deleted file mode 100644 index eb3f19a..0000000 --- a/typescript/orrery/tests/contract.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { inventory, MAX_FILE_BYTES } from "../src/contract.js"; -import { inventoryDigest, objectPath } from "../src/server.js"; - -const graph = { - name: "graph.orrery.json", - role: "graph", - bytes: 100, - sha256: "a".repeat(64), -}; -const valid = { version: 1, publication_id: "uk-run-123", files: [graph] }; -describe("publication inventory", () => { - test("canonical order and content-addressed paths", () => { - const evidence = { - ...graph, - name: "execution.evidence.json", - role: "index", - }; - const a = inventory({ ...valid, files: [graph, evidence] }); - const b = inventory({ ...valid, files: [evidence, graph] }); - expect(inventoryDigest(a)).toBe(inventoryDigest(b)); - expect(objectPath(a, graph.name)).toBe( - `objects/uk-run-123/${inventoryDigest(a)}/graph.orrery.json`, - ); - }); - for (const value of [ - { ...valid, version: 2 }, - { ...valid, publication_id: "../bad" }, - { ...valid, files: [graph, graph] }, - { ...valid, files: [{ ...graph, name: "../graph.orrery.json" }] }, - { ...valid, files: [{ ...graph, bytes: MAX_FILE_BYTES + 1 }] }, - { ...valid, files: [{ ...graph, bytes: -1 }] }, - { ...valid, files: [{ ...graph, sha256: "wrong" }] }, - { ...valid, files: [{ ...graph, name: "private.h5", role: "manifest" }] }, - { - ...valid, - files: [{ ...graph, name: "unreviewed.json", role: "manifest" }], - }, - { ...valid, files: [] }, - ]) - test(`rejects ${JSON.stringify(value).slice(0, 100)}`, () => - expect(() => inventory(value)).toThrow()); -}); diff --git a/typescript/orrery/tests/reader.test.ts b/typescript/orrery/tests/reader.test.ts deleted file mode 100644 index 5bc02b5..0000000 --- a/typescript/orrery/tests/reader.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { expect, test } from "bun:test"; -import { loadDocument, type Fetcher } from "../src/browser.js"; -import { - createPublicationReader, - inventoryDigest, - sha256, -} from "../src/server.js"; -import { inventory, type Publication } from "../src/contract.js"; - -const document = { - schemaVersion: "graph-explorer/v1", - id: "fixture", - title: "Fixture", - nodes: [], - edges: [], -}; -function fixture(payload = JSON.stringify(document)): Publication { - const value = inventory({ - version: 1, - publication_id: "graph", - files: [ - { - name: "graph.orrery.json", - role: "graph", - bytes: new TextEncoder().encode(payload).length, - sha256: sha256(payload), - }, - ], - }); - return { - ...value, - inventory_sha256: inventoryDigest(value), - published_at: "2026-10-08T00:00:00Z", - files: value.files.map((file) => ({ - ...file, - url: "https://example.public.blob.vercel-storage.com/graph.json", - })), - }; -} -function fetcher(saved: unknown, payload = JSON.stringify(document)): Fetcher { - let calls = 0; - return async (_url, init) => { - expect(init?.credentials).toBe("omit"); - expect(init?.redirect).toBe("error"); - return ++calls === 1 ? Response.json(saved) : new Response(payload); - }; -} -const signal = () => new AbortController().signal; -test("browser verifies metadata, exact bytes and graph parser", async () => { - const result = await loadDocument("graph", signal(), fetcher(fixture())); - expect(result.document.id).toBe("fixture"); - expect(result.digest).toBe(sha256(JSON.stringify(document))); -}); -test("missing publications return an explicit error", async () => { - await expect( - loadDocument( - "graph", - signal(), - async () => new Response("", { status: 404 }), - ), - ).rejects.toThrow("not found"); -}); -for (const field of [ - "publication_id", - "inventory_sha256", - "published_at", -] as const) { - test("rejects invalid " + field, async () => { - const saved = { ...fixture(), [field]: "invalid" }; - await expect( - loadDocument("graph", signal(), fetcher(saved)), - ).rejects.toThrow(); - }); -} -for (const url of [ - "http://example.public.blob.vercel-storage.com/x", - "https://example.com/x", - "https://user:secret@example.public.blob.vercel-storage.com/x", -]) { - test("rejects credential-bearing or non-storage URL " + url, async () => { - const saved = fixture(); - saved.files[0].url = url; - await expect( - loadDocument("graph", signal(), fetcher(saved)), - ).rejects.toThrow(); - }); -} -test("rejects modified graph bytes", async () => { - await expect( - loadDocument( - "graph", - signal(), - fetcher(fixture(), "x".repeat(JSON.stringify(document).length)), - ), - ).rejects.toThrow("digest"); -}); -test("bounds graph streaming before parsing", async () => { - await expect( - loadDocument( - "graph", - signal(), - fetcher(fixture(), JSON.stringify(document) + "extra"), - ), - ).rejects.toThrow("size limit"); -}); -test("bounds metadata streaming", async () => { - await expect( - loadDocument( - "graph", - signal(), - async () => new Response("x".repeat(256 * 1024 + 1)), - ), - ).rejects.toThrow("size limit"); -}); -test("rejects a valid but incorrect inventory digest", async () => { - await expect( - loadDocument( - "graph", - signal(), - fetcher({ ...fixture(), inventory_sha256: "a".repeat(64) }), - ), - ).rejects.toThrow("Inventory digest"); -}); -test("server reads only completed publications and listed evidence", async () => { - const saved = fixture(); - const reads: string[] = []; - const reader = createPublicationReader({ - async read(path, maximum) { - reads.push(path); - expect(maximum).toBe(256 * 1024); - return path === "publications/graph.json" - ? { - bytes: new TextEncoder().encode(JSON.stringify(saved)), - url: "unused", - } - : null; - }, - }); - expect(await reader.read("missing")).toBeNull(); - expect((await reader.read("graph"))?.publication_id).toBe("graph"); - expect(await reader.evidence("graph", "private.h5")).toBeNull(); - expect(await reader.evidence("graph", "graph.orrery.json")).toBe( - saved.files[0].url, - ); - expect(reads.every((path) => path.startsWith("publications/"))).toBe(true); -}); -test("server rejects corrupt stored metadata", async () => { - const saved = { ...fixture(), inventory_sha256: "a".repeat(64) }; - const reader = createPublicationReader({ - async read() { - return { - bytes: new TextEncoder().encode(JSON.stringify(saved)), - url: "unused", - }; - }, - }); - await expect(reader.read("graph")).rejects.toThrow( - "inventory_digest_mismatch", - ); -}); diff --git a/typescript/orrery/tsconfig.build.json b/typescript/orrery/tsconfig.build.json deleted file mode 100644 index 0f6eea2..0000000 --- a/typescript/orrery/tsconfig.build.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": false, - "declaration": true, - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"], - "exclude": ["tests"] -} diff --git a/typescript/orrery/tsconfig.json b/typescript/orrery/tsconfig.json deleted file mode 100644 index 8c1f9cd..0000000 --- a/typescript/orrery/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "lib": ["ES2023", "DOM"], - "strict": true, - "skipLibCheck": true, - "types": ["bun"], - "noEmit": true - }, - "include": ["src", "tests"] -}