diff --git a/.github/workflows/hypercerts-api.yml b/.github/workflows/hypercerts-api.yml new file mode 100644 index 00000000..6b89cc44 --- /dev/null +++ b/.github/workflows/hypercerts-api.yml @@ -0,0 +1,49 @@ +name: Hypercerts API checks + +on: + pull_request: + paths: + - "hypercerts-api/**" + - ".github/workflows/hypercerts-api.yml" + push: + branches: [main, dev] + paths: + - "hypercerts-api/**" + - ".github/workflows/hypercerts-api.yml" + +permissions: + contents: read + +jobs: + checks: + runs-on: ubuntu-latest + defaults: + run: + working-directory: hypercerts-api + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: leafo/gh-actions-lua@6919171ccf181b826f44b9bca76307b577217377 # v13.0.0 + with: + luaVersion: "5.4" + + - uses: leafo/gh-actions-luarocks@35d062def313a7699a0d513e996bcf4352f05389 # v6.1.0 + + - name: Install Luacheck + run: luarocks install luacheck 1.2.0 + + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: 10.15.1 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: hypercerts-api/pnpm-lock.yaml + + - name: Install Node dependencies + run: pnpm install --frozen-lockfile + + - name: Lint and test + run: pnpm check diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..b143c304 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,14 @@ +# Agent instructions + +## Map +- `src/`, `crates/`, `tests/`, `migrations/`: Rust server, crates, tests, and database migrations. +- `web/`: Next.js app (npm). `packages/`: Bun workspace SDKs. `scripts/`: repo tooling. +- `hypercerts-api/`: standalone pnpm toolkit for installing/testing Hypercerts XRPC assets; this foundation branch has tooling, not a complete installable API. It is not part of the root workspace. + +## Checks +Run checks for the area changed: +- Rust: `cargo fmt -- --check`; `cargo clippy --workspace --all-targets -- -D warnings`; unit tests: `cargo test --lib` and `cargo test --workspace --exclude happyview`. +- DB-backed tests: `cargo test --tests` with `TEST_DATABASE_URL` set. Start local Postgres with `docker compose -f docker-compose.test.yml up -d`. +- Web: from `web/`, `npm ci && npm run build` (lint: `npm run lint`). +- SDK package: from the root, run `bun install`, then `bun run --filter '' build`, `bun run --filter '' typecheck`, and `bun run --filter '' test`. +- Hypercerts API: from `hypercerts-api/`, `pnpm check`. diff --git a/hypercerts-api/.luacheckrc b/hypercerts-api/.luacheckrc new file mode 100644 index 00000000..b6358c8a --- /dev/null +++ b/hypercerts-api/.luacheckrc @@ -0,0 +1,5 @@ +std = "lua54" +globals = { "db", "json", "params", "toarray", "handle" } +unused_args = true +-- Generated SQL expressions can be long; keep Luacheck focused on code issues. +max_line_length = false diff --git a/hypercerts-api/README.md b/hypercerts-api/README.md new file mode 100644 index 00000000..fa3f26fa --- /dev/null +++ b/hypercerts-api/README.md @@ -0,0 +1,80 @@ +# Hypercerts API for HappyView + +This directory contains the tooling for building and testing a Hypercerts XRPC API on HappyView. It provides a reusable installer for HappyView admin assets (Lexicons and Lua scripts), pinned Hypercerts Lexicon dependencies, and test fixtures for checking record behavior. The installer lets API modules declare their assets together, install missing assets in dependency order, and refuse to overwrite assets that differ from what is already installed. + +**Current status:** This branch contains the installer and offline test tooling, not an installable API. There is no production `manifest.json` or Lua handler bundle here yet. You can run the unit tests, but do not run `tooling/installer.js` against a HappyView target from this branch. + +## Get started + +From `hypercerts-api/`: + +```sh +pnpm install --frozen-lockfile +pnpm check +``` + +`pnpm check` runs JavaScript and Lua lint, a strict `checkJs` typecheck of the installer, then the offline unit tests. Run `pnpm run typecheck` to run just the typecheck. TypeScript is a development-time checker only: the installer remains JavaScript and runs directly with Node, with no transpilation. These checks do not require a running HappyView instance or database. The installable API bundle and its endpoint contract tests must be supplied separately before you can install or exercise API endpoints. + +ESLint is installed with the package dependencies. It uses ESLint's recommended checks plus strict equality, no implicit coercion, no shadowed names, no reassigned parameters, no `var`, and `const` where possible. Console output is allowed only in CLI tooling. When a branch contains Lua scripts, Luacheck checks the generated handlers in `lua/endpoints/`; it rejects unknown globals and unused arguments, allowing only HappyView's `db`, `json`, `params`, `toarray`, and `handle`. It targets Lua 5.4 and skips line-length checks for long SQL expressions. Install Lua 5.4 and LuaRocks, then run `luarocks --lua-version=5.4 --local install luacheck 1.2.0` to enable that check locally. The lint runner also finds the default `~/.luarocks/bin` install if it is not on `PATH`. On this foundation-only branch, which has no Lua files, the Lua check reports that it is skipped. If Lua sources exist but generated handlers are missing, run `pnpm build:lua` first. + +## How an API bundle is installed + +Once an API bundle supplies a root `manifest.json` and its referenced module manifests, the installer loads Lexicon and Lua script sources, checks dependencies and existing admin assets, and only writes missing assets. It skips unchanged assets and stops on conflicts so you can resolve them manually. An installation is not an all-or-nothing transaction: if a write fails, the installer reports what it already installed and what remains. + +```mermaid +flowchart TD + Start(["Run installer"]) --> Load + + subgraph S1["1. Load"] + Load["Read manifests and source files"] --> Valid{"All valid?"} + end + + subgraph S2["2. Plan"] + Order["Sort assets by dependencies"] + end + + subgraph S3["3. Check server"] + Fetch["Fetch what HappyView already has"] --> Conflict{"Any installed asset
differs from ours?"} + end + + subgraph S4["4. Install"] + Each["Take next asset"] --> Same{"Already identical?"} + Same -- Yes --> Skip["Skip it"] + Same -- No --> Post["Create it"] + Post --> OK{"Worked?"} + Skip --> More{"More assets?"} + OK -- Yes --> More + More -- Yes --> Each + end + + Valid -- Yes --> Order --> Fetch + Conflict -- No --> Each + More -- No --> Done(["Print what changed
and what didn't"]) + + Valid -- No --> Stop1["Stop: fix local files"] + Conflict -- Yes --> Stop2["Stop: resolve conflict by hand"] + OK -- No --> Stop3["Stop: report progress
(no rollback)"] + + classDef stop fill:#fde2e2,stroke:#c0392b,color:#7b1f1f + classDef done fill:#e2f5e6,stroke:#27ae60,color:#1e5631 + class Stop1,Stop2,Stop3 stop + class Done done +``` + +The root manifest lists module manifests once: + +```json +{ "modules": ["modules/shared/manifest.json", "modules/location/manifest.json"] } +``` + +Each module manifest declares `{ "assets": [...] }`. Assets have an `id`, `kind` (`lexicon` or `script`), `config`, and optional `dependsOn` asset IDs. Lexicons point to a `packagePath` in `@hypercerts-org/lexicon` or a local `path`; scripts use a local `path`. Local paths are relative to the **module manifest that declares them**. Declare shared assets in one module and reference their IDs from dependent modules. The installer rejects duplicate IDs, missing dependencies, cycles, and invalid source files before making admin requests. + +With a complete bundle and an explicitly approved HappyView target, the entry point is `node tooling/installer.js`. Set `HAPPYVIEW_BASE_URL` and `HAPPYVIEW_ADMIN_TOKEN` in the environment. The installer sends `Authorization: Bearer `; session-cookie authentication is not supported. Remote targets must use HTTPS; HTTP is allowed only on `localhost`, `127.0.0.1`, or `::1`. URL credentials and HTTP redirects are rejected. + +The admin API key must have `lexicons:read` and `lexicons:create` for lexicon assets. If the bundle includes scripts, it also needs `scripts:read` and `scripts:manage`. If a manifest requests backfill for a new record lexicon, `backfill:create` is additionally needed to start that job; without it, the lexicon is still uploaded but no backfill starts. Do not use the installer until the bundle includes its production manifests, handlers, and domain-specific completeness tests. + +## Test fixtures (disposable databases only) + +The fixture tools seed records directly into PostgreSQL, bypassing HappyView ingestion. Use them **only with a separately approved, disposable loopback test database**, never persistent data. The `seed:test` and `seed:bad-dates` scripts require `HAPPYVIEW_DISPOSABLE_TEST_TARGET=YES`, a loopback `PGHOST`, a `PGDATABASE` name containing a `test` marker, and an absolute `PSQL_PATH` to a trusted `psql` executable. Standard `PGPORT` and `PGUSER` can select the test instance and user. The normal `seed:test` command seeds location, profile, and organization examples by default; `seed:bad-dates` seeds only location examples by default. Both tools also accept supplied record rows through `buildSeedInput` and `buildBadDateSeedInput` in `tooling/seed.js`. + +Fixture CIDs are computed from stored record JSON with `@atcute/cbor` and `@atcute/cid`. If you seeded an older version of the blob-backed fixture, reseed the **disposable test database** before comparing CIDs: the earlier `jsonToLex` conversion produced a different CID. diff --git a/hypercerts-api/eslint.config.js b/hypercerts-api/eslint.config.js new file mode 100644 index 00000000..2a289d22 --- /dev/null +++ b/hypercerts-api/eslint.config.js @@ -0,0 +1,32 @@ +import js from "@eslint/js"; +import globals from "globals"; + +export default [ + { + ignores: ["node_modules/**"], + }, + { + files: ["**/*.js"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: globals.node, + }, + rules: { + ...js.configs.recommended.rules, + eqeqeq: ["error", "always", { null: "ignore" }], + "no-console": "error", + "no-implicit-coercion": "error", + "no-param-reassign": ["error", { props: true }], + "no-shadow": "error", + "no-var": "error", + "prefer-const": ["error", { destructuring: "all" }], + }, + }, + { + files: ["tooling/installer.js", "tooling/seed.js", "tooling/lint-lua.js"], + rules: { + "no-console": "off", + }, + }, +]; diff --git a/hypercerts-api/package.json b/hypercerts-api/package.json new file mode 100644 index 00000000..7b2b3f70 --- /dev/null +++ b/hypercerts-api/package.json @@ -0,0 +1,29 @@ +{ + "name": "@happyview/hypercerts-api-kit", + "private": true, + "type": "module", + "dependencies": { + "@atcute/cbor": "2.3.2", + "@atcute/cid": "2.4.1", + "@atproto/lexicon": "0.6.2", + "@atproto/syntax": "0.5.4", + "@hypercerts-org/lexicon": "1.4.0" + }, + "scripts": { + "lint:js": "eslint --max-warnings=0 .", + "lint:lua": "node tooling/lint-lua.js", + "lint": "pnpm run lint:js && pnpm run lint:lua", + "typecheck": "tsc --project tsconfig.json", + "check": "pnpm run lint && pnpm run typecheck && pnpm run test:unit", + "test:unit": "node --test tooling/*.test.js tests/fixtures/*.test.js tests/contracts/helpers.test.js", + "seed:test": "node tooling/seed.js", + "seed:bad-dates": "node tooling/seed.js --bad-dates" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "24.3.0", + "eslint": "^10.11.0", + "globals": "^17.12.0", + "typescript": "5.9.3" + } +} diff --git a/hypercerts-api/pnpm-lock.yaml b/hypercerts-api/pnpm-lock.yaml new file mode 100644 index 00000000..994b155c --- /dev/null +++ b/hypercerts-api/pnpm-lock.yaml @@ -0,0 +1,867 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@atcute/cbor': + specifier: 2.3.2 + version: 2.3.2 + '@atcute/cid': + specifier: 2.4.1 + version: 2.4.1 + '@atproto/lexicon': + specifier: 0.6.2 + version: 0.6.2 + '@atproto/syntax': + specifier: 0.5.4 + version: 0.5.4 + '@hypercerts-org/lexicon': + specifier: 1.4.0 + version: 1.4.0(@atproto/xrpc@0.8.13) + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.11.0) + '@types/node': + specifier: 24.3.0 + version: 24.3.0 + eslint: + specifier: ^10.11.0 + version: 10.11.0 + globals: + specifier: ^17.12.0 + version: 17.12.0 + typescript: + specifier: 5.9.3 + version: 5.9.3 + +packages: + + '@atcute/cbor@2.3.2': + resolution: {integrity: sha512-xP2SORSau/VVI00x2V4BjwIkHr6EQ7l/MXEOPaa4LGYtePFc4gnD4L1yN10dT5NEuUnvGEuCh6arLB7gz1smVQ==} + + '@atcute/cid@2.4.1': + resolution: {integrity: sha512-bwhna69RCv7yetXudtj+2qrMPYvhhIQqvJz6YUpUS98v7OdF3X2dnye9Nig2NDrklZcuyOsu7sQo7GOykJXRLQ==} + + '@atcute/multibase@1.2.5': + resolution: {integrity: sha512-cReTONgYpQo/VHD3ZmzPNoyBKJgSk1J4h//cvvdVVJBMar+SjlQ/sUXeTjQfuyfmDKv+TLKhmutLcvbMcQ9Rvw==} + + '@atcute/uint8array@1.2.0': + resolution: {integrity: sha512-KoBGTbV4lS8zXNu91osM3FecrH3NUnxNPsBAkaNtn6uf/p240245qoLn7keFpRM0npX522X8R+XBBb2rvtE7Rg==} + + '@atproto/common-web@0.4.21': + resolution: {integrity: sha512-Odq+wdk3YNasGCjjlpl3bCIPvqYHige5DLfMkIffNv/2PI/iIj5ZvAvMvJlJ59OhReKSxtpI0invx5UQPc3+fw==} + + '@atproto/common-web@0.5.12': + resolution: {integrity: sha512-FumNnUOUsk68gP+jU0sXY2wY2piqFV9/qU2YaoQq/IZknhJBlLYCzjQXXCN0FCzxAZWD0YUzgydeyuRvFoAxfA==} + engines: {node: '>=22'} + + '@atproto/lex-data@0.0.15': + resolution: {integrity: sha512-ZsbGiaM5S3CnGrcTMbDGON3bLZzCi/Mx9UvcMREKSRujnF68eHgMiXxJqvykP7+QpOX6tYCK93axZkuJVhtSEw==} + + '@atproto/lex-data@0.1.7': + resolution: {integrity: sha512-kW/dPLqo/WgCLV+XESR4JKwV6c1rZWJGOfuPupZGTjEDAKoBbKXdaEzX9/1vKQYbZ9U3j0DS/n7OFFK7wBugyQ==} + engines: {node: '>=22'} + + '@atproto/lex-json@0.0.16': + resolution: {integrity: sha512-IgLgQ0krshVlrIYZ+heTBDbCnM3LmAgWvsaYn5MxvKA3LcBot3PG3ptdO8VOweVZ+WgCLuo39cz9EbUmIbqdtg==} + + '@atproto/lex-json@0.1.6': + resolution: {integrity: sha512-mvrAd0lbyuecIHjyld8QN6MN6CBf4j0GCxLzegsvLh0SvDf+GbYWklkcQqmITL44yFQOwmA/QNIQj0Uvh7+R/g==} + engines: {node: '>=22'} + + '@atproto/lexicon@0.6.2': + resolution: {integrity: sha512-p3Ly6hinVZW0ETuAXZMeUGwuMm3g8HvQMQ41yyEE6AL0hAkfeKFaZKos6BdBrr6CjkpbrDZqE8M+5+QOceysMw==} + + '@atproto/lexicon@0.7.14': + resolution: {integrity: sha512-hDG2+61JTrvxDFtoojDFxKmZkFVl/lR/jFi82gSN0zNDjr+dlpUGKlG9MoPBkUKK83+Pi+I2jZ55xXboJq6JTA==} + engines: {node: '>=22'} + + '@atproto/syntax@0.5.4': + resolution: {integrity: sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw==} + + '@atproto/syntax@0.7.6': + resolution: {integrity: sha512-luKQTcWw1H1jLmYvX34ldBqNjz2orF082njmcF9fxbWTqVhsO+TpY0FHRKVPoV7dwL0Y3L16DNz1ma0LFGH25g==} + engines: {node: '>=22'} + + '@atproto/xrpc@0.8.13': + resolution: {integrity: sha512-htpi93/Zqyk5WsT+RrWemxMcJFJx2M4VT8i8avuWJTyr7AZ42qvPsIdVsaPdOOS3rZwodq/SdZXJFgspJ4yc5g==} + engines: {node: '>=22'} + + '@cacheable/memory@2.2.0': + resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} + + '@cacheable/utils@2.5.0': + resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.3': + resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@hypercerts-org/lexicon@1.4.0': + resolution: {integrity: sha512-M1yG6+EI1yOpCZU6CO2plZcMS9fcXq8i/h/gXM/sj9txLH4Roo6de5Rs7L7Zhn/8iYoIp+akuGoolZ0e3EuUcg==} + peerDependencies: + '@atproto/xrpc': '*' + + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.3.0': + resolution: {integrity: sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + + cacheable@2.5.0: + resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.11.0: + resolution: {integrity: sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + file-entry-cache@11.1.5: + resolution: {integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@6.1.23: + resolution: {integrity: sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@17.12.0: + resolution: {integrity: sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==} + engines: {node: '>=18'} + + hashery@1.5.1: + resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} + engines: {node: '>=20'} + + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + + hookified@2.2.0: + resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iso-datestring-validator@2.2.2: + resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multiformats@13.4.2: + resolution: {integrity: sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==} + + multiformats@9.9.0: + resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qified@0.10.1: + resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} + engines: {node: '>=20'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uint8arrays@3.0.0: + resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==} + + undici-types@7.10.0: + resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} + + unicode-segmenter@0.14.5: + resolution: {integrity: sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@atcute/cbor@2.3.2': + dependencies: + '@atcute/cid': 2.4.1 + '@atcute/multibase': 1.2.5 + '@atcute/uint8array': 1.2.0 + + '@atcute/cid@2.4.1': + dependencies: + '@atcute/multibase': 1.2.5 + '@atcute/uint8array': 1.2.0 + + '@atcute/multibase@1.2.5': + dependencies: + '@atcute/uint8array': 1.2.0 + + '@atcute/uint8array@1.2.0': {} + + '@atproto/common-web@0.4.21': + dependencies: + '@atproto/lex-data': 0.0.15 + '@atproto/lex-json': 0.0.16 + '@atproto/syntax': 0.5.4 + zod: 3.25.76 + + '@atproto/common-web@0.5.12': + dependencies: + '@atproto/lex-data': 0.1.7 + '@atproto/lex-json': 0.1.6 + '@atproto/syntax': 0.7.6 + zod: 3.25.76 + + '@atproto/lex-data@0.0.15': + dependencies: + multiformats: 9.9.0 + tslib: 2.8.1 + uint8arrays: 3.0.0 + unicode-segmenter: 0.14.5 + + '@atproto/lex-data@0.1.7': + dependencies: + multiformats: 13.4.2 + tslib: 2.8.1 + unicode-segmenter: 0.14.5 + + '@atproto/lex-json@0.0.16': + dependencies: + '@atproto/lex-data': 0.0.15 + tslib: 2.8.1 + + '@atproto/lex-json@0.1.6': + dependencies: + '@atproto/lex-data': 0.1.7 + tslib: 2.8.1 + + '@atproto/lexicon@0.6.2': + dependencies: + '@atproto/common-web': 0.4.21 + '@atproto/syntax': 0.5.4 + iso-datestring-validator: 2.2.2 + multiformats: 9.9.0 + zod: 3.25.76 + + '@atproto/lexicon@0.7.14': + dependencies: + '@atproto/common-web': 0.5.12 + '@atproto/syntax': 0.7.6 + multiformats: 13.4.2 + zod: 3.25.76 + + '@atproto/syntax@0.5.4': + dependencies: + tslib: 2.8.1 + + '@atproto/syntax@0.7.6': + dependencies: + iso-datestring-validator: 2.2.2 + tslib: 2.8.1 + + '@atproto/xrpc@0.8.13': + dependencies: + '@atproto/lexicon': 0.7.14 + zod: 3.25.76 + + '@cacheable/memory@2.2.0': + dependencies: + '@cacheable/utils': 2.5.0 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/utils@2.5.0': + dependencies: + hashery: 1.5.1 + keyv: 5.6.0 + + '@eslint-community/eslint-utils@4.10.1(eslint@10.11.0)': + dependencies: + eslint: 10.11.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.6 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.7.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.11.0)': + optionalDependencies: + eslint: 10.11.0 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.3': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@hypercerts-org/lexicon@1.4.0(@atproto/xrpc@0.8.13)': + dependencies: + '@atproto/lexicon': 0.6.2 + '@atproto/xrpc': 0.8.13 + multiformats: 13.4.2 + + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.1 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.3.0': + dependencies: + undici-types: 7.10.0 + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + balanced-match@4.0.4: {} + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + cacheable@2.5.0: + dependencies: + '@cacheable/memory': 2.2.0 + '@cacheable/utils': 2.5.0 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.10.1 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + escape-string-regexp@4.0.0: {} + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.11.0: + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.7.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.3 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 11.1.5 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.6 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + file-entry-cache@11.1.5: + dependencies: + flat-cache: 6.1.23 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@6.1.23: + dependencies: + cacheable: 2.5.0 + flatted: 3.4.4 + hookified: 1.15.1 + + flatted@3.4.4: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@17.12.0: {} + + hashery@1.5.1: + dependencies: + hookified: 1.15.1 + + hookified@1.15.1: {} + + hookified@2.2.0: {} + + ignore@5.3.2: {} + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + iso-datestring-validator@2.2.2: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + + ms@2.1.3: {} + + multiformats@13.4.2: {} + + multiformats@9.9.0: {} + + natural-compare@1.4.0: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + prelude-ls@1.2.1: {} + + punycode@2.3.1: {} + + qified@0.10.1: + dependencies: + hookified: 2.2.0 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript@5.9.3: {} + + uint8arrays@3.0.0: + dependencies: + multiformats: 9.9.0 + + undici-types@7.10.0: {} + + unicode-segmenter@0.14.5: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yocto-queue@0.1.0: {} + + zod@3.25.76: {} diff --git a/hypercerts-api/tests/contracts/helpers.js b/hypercerts-api/tests/contracts/helpers.js new file mode 100644 index 00000000..740e702b --- /dev/null +++ b/hypercerts-api/tests/contracts/helpers.js @@ -0,0 +1,18 @@ +export function requireContractTarget(env = process.env) { + if (!env.HAPPYVIEW_BASE_URL) throw new Error('HAPPYVIEW_BASE_URL must point to the supplied running local HappyView instance'); + const url = new URL(env.HAPPYVIEW_BASE_URL); + if (!['http:', 'https:'].includes(url.protocol)) throw new Error('HAPPYVIEW_BASE_URL must use HTTP or HTTPS'); + if (!['localhost', '127.0.0.1', '::1'].includes(url.hostname.replace(/^\[|\]$/g, ''))) throw new Error('HTTP contracts are restricted to a local HappyView URL'); + return url; +} + +export function contractUrl(baseUrl, nsid, params = {}) { + const url = new URL(`/xrpc/${nsid}`, baseUrl); + for (const [key, value] of Object.entries(params)) { + if (Array.isArray(value)) for (const item of value) url.searchParams.append(key, item); + else if (value !== undefined && value !== null) url.searchParams.append(key, String(value)); + } + // HappyView's current query decoder mishandles '+' as a space; encode spaces as %20. + url.search = url.search.replaceAll('+', '%20'); + return url.toString(); +} diff --git a/hypercerts-api/tests/contracts/helpers.test.js b/hypercerts-api/tests/contracts/helpers.test.js new file mode 100644 index 00000000..bcf881da --- /dev/null +++ b/hypercerts-api/tests/contracts/helpers.test.js @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { contractUrl, requireContractTarget } from './helpers.js'; + +test('repeated arrays use unbracketed keys, omit empty options, and preserve URL encoding', () => { + assert.equal(contractUrl('http://127.0.0.1:8080', 'org.example.search', { + authors: ['did:plc:a', 'did:plc:b'], empty: [], search: 'river bank + 50%', + }), 'http://127.0.0.1:8080/xrpc/org.example.search?authors=did%3Aplc%3Aa&authors=did%3Aplc%3Ab&search=river%20bank%20%2B%2050%25'); +}); + +test('contract target must be an explicit supplied HTTP(S) URL', () => { + assert.throws(() => requireContractTarget({}), /HAPPYVIEW_BASE_URL/); + assert.equal(requireContractTarget({ HAPPYVIEW_BASE_URL: 'http://127.0.0.1:8000' }).origin, 'http://127.0.0.1:8000'); + assert.throws(() => requireContractTarget({ HAPPYVIEW_BASE_URL: 'https://happyview.example.com' }), /restricted to a local HappyView URL/); +}); diff --git a/hypercerts-api/tests/fixtures/bad-dates.js b/hypercerts-api/tests/fixtures/bad-dates.js new file mode 100644 index 00000000..daefbdd8 --- /dev/null +++ b/hypercerts-api/tests/fixtures/bad-dates.js @@ -0,0 +1,42 @@ +// Deliberately malformed record dates: use only with explicitly disposable test targets. +// These bypass Lexicon validation and must never be added to records.js or routine seeding. +import { encode } from '@atcute/cbor'; +import * as CID from '@atcute/cid'; + +const cases = [ + ['badmissing', undefined, '2025-01-02T03:04:05.123456Z'], + ['badnull', null, '2025-01-02T03:04:05.123456Z'], + ['badnumber', 123, '2025-01-02T03:04:05.123456Z'], + ['badarray', ['2025-01-01T00:00:00Z'], '2025-01-02T03:04:05.123456Z'], + ['badtext', 'not-a-date', '2025-01-02T03:04:05.123456Z'], + ['badnozone', '2025-01-01T00:00:00', '2025-01-02T03:04:05.123456Z'], + ['badhour', '2025-01-01T24:00:00Z', '2025-01-02T03:04:05.123456Z'], + ['badzone', '2025-01-01T00:00:00+00:60', '2025-01-02T03:04:05.123456Z'], + ['badcalendar', '2025-02-29T00:00:00Z', '2025-01-02T03:04:05.123456Z'], + ['badunindexed', 'invalid', null], + ['goodoffset', '2025-01-02T08:34:05.123456+05:30', '2025-01-06T00:00:00Z'], + ['goodfraction', '2025-01-02T03:04:05.123456Z', '2025-01-07T00:00:00Z'], + ['goodnano', '2025-01-02T03:04:05.123456789Z', '2025-01-09T00:00:00Z'], + ['goodearlier', '2025-01-01T00:00:00Z', '2025-01-08T00:00:00Z'], +]; + +export async function makeDateCaseRows(base, { did, decorateRecord = (record) => record }) { + return Promise.all(cases.map(async ([rkey, createdAt, indexedAt]) => { + const record = decorateRecord({ ...base.record }, rkey); + if (createdAt === undefined) delete record.createdAt; + else record.createdAt = createdAt; + return { + uri: `at://${did}/${base.collection}/${rkey}`, did, collection: base.collection, rkey, record, indexedAt, + storedAt: '2025-01-02T03:04:05.123456Z', + cid: CID.toString(await CID.create(0x71, encode(record))), + }; + })); +} + +export function badDateSeedSql(rows, { disposableTestTarget = false } = {}) { + if (disposableTestTarget !== true) throw new Error('Bad-date seeding requires explicit disposable-test target opt-in'); + return rows.map((row) => ({ + sql: 'INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, indexed_at, created_at) VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, cid = EXCLUDED.cid, indexed_at = EXCLUDED.indexed_at, created_at = EXCLUDED.created_at', + params: [row.uri, row.did, row.collection, row.rkey, JSON.stringify(row.record), row.cid, row.indexedAt, row.storedAt], + })); +} diff --git a/hypercerts-api/tests/fixtures/bad-dates.test.js b/hypercerts-api/tests/fixtures/bad-dates.test.js new file mode 100644 index 00000000..1579bbfd --- /dev/null +++ b/hypercerts-api/tests/fixtures/bad-dates.test.js @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { makeDateCaseRows, badDateSeedSql } from './bad-dates.js'; +import { profileRecords } from './records.js'; + +test('date cases apply to a different record collection without changing the base fixture', async () => { + const base = profileRecords[0]; + const rows = await makeDateCaseRows(base, { did: 'did:web:bad-date-profiles.example' }); + const missing = rows.find(({ rkey }) => rkey === 'badmissing'); + const malformed = rows.find(({ rkey }) => rkey === 'badcalendar'); + const offset = rows.find(({ rkey }) => rkey === 'goodoffset'); + assert.equal(missing.uri, 'at://did:web:bad-date-profiles.example/app.certified.actor.profile/badmissing'); + assert.equal(missing.record.$type, 'app.certified.actor.profile'); + assert.equal(missing.record.displayName, 'Test Publisher'); + assert.equal(Object.hasOwn(missing.record, 'createdAt'), false); + assert.equal(malformed.record.createdAt, '2025-02-29T00:00:00Z'); + assert.equal(offset.record.createdAt, '2025-01-02T08:34:05.123456+05:30'); + assert.equal(rows.find(({ rkey }) => rkey === 'badunindexed').indexedAt, null); + assert.equal(base.record.createdAt, '2025-01-02T03:04:05.000Z'); + assert.ok(rows.every(({ collection, cid }) => collection === base.collection && typeof cid === 'string')); +}); + +test('shared date rows require disposable-target opt-in before producing seed SQL', async () => { + const rows = await makeDateCaseRows(profileRecords[0], { did: 'did:web:bad-date-profiles.example' }); + assert.throws(() => badDateSeedSql(rows), /disposable-test target opt-in/); + const statements = badDateSeedSql(rows, { disposableTestTarget: true }); + assert.equal(statements.length, 14); + const missing = statements.find(({ params }) => params[3] === 'badmissing'); + assert.equal(missing.params[2], 'app.certified.actor.profile'); + assert.equal(Object.hasOwn(JSON.parse(missing.params[4]), 'createdAt'), false); + const unindexed = statements.find(({ params }) => params[3] === 'badunindexed'); + assert.equal(unindexed.params[6], null); + assert.equal(unindexed.params[7], '2025-01-02T03:04:05.123456Z'); +}); diff --git a/hypercerts-api/tests/fixtures/fixtures.test.js b/hypercerts-api/tests/fixtures/fixtures.test.js new file mode 100644 index 00000000..8581a494 --- /dev/null +++ b/hypercerts-api/tests/fixtures/fixtures.test.js @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as CID from '@atcute/cid'; +import { isValidDid, isValidTid } from '@atproto/syntax'; +import { locationRecords, profileRecords, organizationRecords, seedSql } from './records.js'; + +test('fixtures have consistent full AT-URIs, valid DID/TID/CID identifiers, types, and fixed timestamps', () => { + const records = [...locationRecords, ...profileRecords, ...organizationRecords]; + for (const row of records) { + assert.equal(row.uri, `at://${row.did}/${row.collection}/${row.rkey}`); + assert.equal(row.record.$type, row.collection); + assert.equal(isValidDid(row.did), true); + if (row.collection === 'app.certified.location') assert.equal(isValidTid(row.rkey), true); + const cid = CID.fromString(row.cid); + assert.equal(cid.version, 1); + assert.equal(cid.codec, 0x71); + assert.equal(cid.digest.codec, 0x12); + assert.equal(cid.digest.contents.length, 32); + assert.equal(row.indexedAt, '2025-01-02T03:04:05.000Z'); + } +}); + +test('location fixtures cover the smallBlob union variant with a shaped blob reference', () => { + const fixture = locationRecords.find(({ record }) => record.location?.$type === 'org.hypercerts.defs#smallBlob'); + assert.ok(fixture, 'expected a location fixture using the smallBlob union variant'); + assert.equal(fixture.cid, 'bafyreibttgrp2qdif53ifsfdzndmshje7aqjy6ybiddbu2pwgrwlwctxsm'); + assert.deepEqual(fixture.record.location.blob, { + $type: 'blob', + ref: { $link: 'bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy' }, + mimeType: 'application/geo+json', + size: 68, + }); +}); + +test('fixture loader requires explicit disposable-target opt-in and parameterizes records only', () => { + const records = [...locationRecords, ...profileRecords, ...organizationRecords]; + assert.throws(() => seedSql(records, {}), /explicit disposable-test target opt-in/); + const statements = seedSql(records, { disposableTestTarget: true }); + assert.equal(statements.length, records.length); + for (const { sql, params } of statements) { + assert.match(sql, /INSERT INTO happyview_records/); + assert.doesNotMatch(sql, /DELETE|TRUNCATE|DROP/i); + assert.equal(params.length, 7); + assert.doesNotMatch(sql, /\bat:\/\//); + } +}); + +test('seedSql converts only supplied rows, including another collection', () => { + const custom = { + uri: 'at://did:web:custom.example/org.hypercerts.custom/item', + did: 'did:web:custom.example', collection: 'org.hypercerts.custom', rkey: 'item', + record: { $type: 'org.hypercerts.custom', name: 'Custom' }, + cid: 'bafycustom', indexedAt: '2025-01-02T03:04:05.000Z', + }; + const statements = seedSql([custom], { disposableTestTarget: true }); + assert.equal(statements.length, 1); + assert.deepEqual(statements[0].params, [custom.uri, custom.did, custom.collection, custom.rkey, '{"$type":"org.hypercerts.custom","name":"Custom"}', custom.cid, custom.indexedAt]); + assert.match(statements[0].sql, /INSERT INTO happyview_records/); +}); diff --git a/hypercerts-api/tests/fixtures/records.js b/hypercerts-api/tests/fixtures/records.js new file mode 100644 index 00000000..7cc5fb1f --- /dev/null +++ b/hypercerts-api/tests/fixtures/records.js @@ -0,0 +1,88 @@ +import { encode } from '@atcute/cbor'; +import * as CID from '@atcute/cid'; + +const collections = { + location: 'app.certified.location', + profile: 'app.certified.actor.profile', + organization: 'app.certified.actor.organization', +}; +const did = 'did:plc:abcdefghijklmnopqrstuvwx'; +const indexedAt = '2025-01-02T03:04:05.000Z'; + +async function row(collection, rkey, fields, recordDid = did) { + const record = { $type: collection, ...fields }; + const uri = `at://${recordDid}/${collection}/${rkey}`; + const cid = CID.toString(await CID.create(0x71, encode(record))); + return { uri, did: recordDid, collection, rkey, cid, indexedAt, record }; +} + +export const profileRecords = await Promise.all([ + row(collections.profile, 'self', { + displayName: 'Test Publisher', description: 'Location API deterministic fixture', createdAt: indexedAt, + }), + row(collections.profile, 'self', { displayName: 'Profile-only publisher', createdAt: indexedAt }, 'did:web:profile-only.example'), +]); + +export const organizationRecords = await Promise.all([ + row(collections.organization, 'self', { + organizationType: ['nonprofit'], visibility: 'public', createdAt: indexedAt, + }), + row(collections.organization, 'self', { organizationType: ['community'], createdAt: indexedAt }, 'did:web:organization-only.example'), +]); + +export const locationRecords = await Promise.all([ + row(collections.location, '3jzfcijpj2z2a', { + lpVersion: '1.0.0', srs: 'https://www.opengis.net/def/crs/OGC/1.3/CRS84', locationType: 'geojson', + location: { $type: 'app.certified.location#string', string: '{"type":"Point","coordinates":[89.64,27.47]}' }, name: 'Thimphu Forest', + createdAt: '2025-01-01T00:00:00.000Z', + }), + row(collections.location, '3jzfcijpj2z2b', { + lpVersion: '1.0.0', srs: 'https://www.opengis.net/def/crs/OGC/1.3/CRS84', locationType: 'address', + location: { $type: 'app.certified.location#string', string: 'Wang Chhu riverbank' }, name: 'Wang Chhu Riverbank', + description: 'Urban river monitoring site', createdAt: '2025-01-02T00:00:00.000Z', + }), + row(collections.location, '3jzfcijpj2z2c', { + lpVersion: '1.0.0', srs: 'https://www.opengis.net/def/crs/OGC/1.3/CRS84', locationType: 'geojson', + location: { + $type: 'org.hypercerts.defs#smallBlob', + blob: { + $type: 'blob', + ref: { $link: 'bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy' }, + mimeType: 'application/geo+json', + size: 68, + }, + }, + name: 'Blob-backed test location', + description: 'Synthetic blob reference for offline record-shape and API pass-through tests; blob bytes are not included.', + createdAt: '2025-01-03T00:00:00.000Z', + }), + row(collections.location, '3jzfcijpj2z2d', { + lpVersion: '1.0.0', srs: 'https://www.opengis.net/def/crs/OGC/1.3/CRS84', locationType: '', + location: { $type: 'app.certified.location#string', string: 'Empty location type fixture' }, + name: 'Empty location type fixture', createdAt: '2025-01-04T00:00:00+01:00', + }), + row(collections.location, '3jzfcijpj2z2e', { + lpVersion: '1.0.0', srs: 'https://www.opengis.net/def/crs/OGC/1.3/CRS84', locationType: 'geojson-point', + location: { $type: 'app.certified.location#string', string: 'No linked author records' }, + name: 'Unhydrated author', createdAt: '2025-01-04T00:00:00Z', + }, 'did:web:no-relations.example'), + row(collections.location, '3jzfcijpj2z2f', { + lpVersion: '1.0.0', srs: 'https://www.opengis.net/def/crs/OGC/1.3/CRS84', locationType: 'geojson-point', + location: { $type: 'app.certified.location#string', string: 'Profile without organization' }, + name: 'Profile-only author', createdAt: '2025-01-05T00:00:00Z', + }, 'did:web:profile-only.example'), + row(collections.location, '3jzfcijpj2z2g', { + lpVersion: '1.0.0', srs: 'https://www.opengis.net/def/crs/OGC/1.3/CRS84', locationType: 'geojson-point', + location: { $type: 'app.certified.location#string', string: 'Organization without profile' }, + name: 'Organization-only author', createdAt: '2025-01-06T00:00:00Z', + }, 'did:web:organization-only.example'), +]); + +export function seedSql(rows, { disposableTestTarget = false } = {}) { + if (disposableTestTarget !== true) throw new Error('Seeding requires explicit disposable-test target opt-in'); + if (!Array.isArray(rows) || rows.length === 0) throw new Error('Seeding requires at least one fixture row; supply a nonempty rows array'); + return rows.map((record) => ({ + sql: 'INSERT INTO happyview_records (uri, did, collection, rkey, record, cid, indexed_at, created_at) VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $7) ON CONFLICT (uri) DO UPDATE SET did = EXCLUDED.did, collection = EXCLUDED.collection, rkey = EXCLUDED.rkey, record = EXCLUDED.record, cid = EXCLUDED.cid, indexed_at = EXCLUDED.indexed_at, created_at = EXCLUDED.created_at', + params: [record.uri, record.did, record.collection, record.rkey, JSON.stringify(record.record), record.cid, record.indexedAt], + })); +} diff --git a/hypercerts-api/tooling/installer-bundle.test.js b/hypercerts-api/tooling/installer-bundle.test.js new file mode 100644 index 00000000..5e4f9c96 --- /dev/null +++ b/hypercerts-api/tooling/installer-bundle.test.js @@ -0,0 +1,266 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { loadAssets, applyAssets } from './installer.js'; + +async function bundle(t, modules) { + const root = await mkdtemp(path.join(os.tmpdir(), 'hypercerts-bundle-')); + t.after(() => rm(root, { recursive: true, force: true })); + await writeFile(path.join(root, 'manifest.json'), JSON.stringify({ modules: Object.keys(modules).map((name) => `${name}/manifest.json`) })); + for (const [name, { assets, files = {} }] of Object.entries(modules)) { + await mkdir(path.join(root, name), { recursive: true }); + await writeFile(path.join(root, name, 'manifest.json'), JSON.stringify({ assets })); + for (const [file, content] of Object.entries(files)) { + await mkdir(path.dirname(path.join(root, name, file)), { recursive: true }); + await writeFile(path.join(root, name, file), content); + } + } + return path.join(root, 'manifest.json'); +} + +function client(installed = {}) { + const writes = []; + const reads = []; + return { + writes, reads, + read: async (asset) => { reads.push(asset.id); return installed[asset.id] ?? null; }, + write: async (asset) => { writes.push(asset.id); }, + }; +} + +test('missing or invalid root bundle identifies its path and how to fix it', async (t) => { + const manifest = await bundle(t, { shared: { assets: [] } }); + for (const content of ['{', 'null']) { + await writeFile(manifest, content); + await assert.rejects(() => loadAssets(manifest), (error) => + error.message.includes(manifest) && /bundle/i.test(error.message) && /fix|check|create|add/i.test(error.message)); + } + await rm(manifest); + await assert.rejects(() => loadAssets(manifest), (error) => + error.message.includes(manifest) && /bundle/i.test(error.message) && /fix|check|create|add/i.test(error.message)); +}); + +test('module paths must be nonempty strings and identify their index', async (t) => { + const manifest = await bundle(t, { shared: { assets: [] } }); + for (const modulePath of [null, '', ' ', 42]) { + await writeFile(manifest, JSON.stringify({ modules: [modulePath] })); + await assert.rejects(() => loadAssets(manifest), /Bundle .*modules\[0\].*nonempty.*path.*fix/i); + } +}); + +test('non-string asset source paths fail with the module and asset index', async (t) => { + const manifest = await bundle(t, { shared: { assets: [] } }); + const moduleFile = path.join(path.dirname(manifest), 'shared/manifest.json'); + for (const [kind, field, value] of [ + ['lexicon', 'path', 42], + ['lexicon', 'packagePath', {}], + ['script', 'path', ['handler.lua']], + ]) { + await writeFile(moduleFile, JSON.stringify({ assets: [{ id: 'org.example.asset', kind, [field]: value, config: {} }] })); + await assert.rejects(() => loadAssets(manifest), /Module shared\/manifest\.json.*assets\[0\].*(path|packagePath).*nonempty string/i); + } +}); + +test('invalid asset settings fail before loading their source', async (t) => { + const manifest = await bundle(t, { shared: { assets: [] } }); + const moduleFile = path.join(path.dirname(manifest), 'shared/manifest.json'); + for (const [kind, config, field] of [ + ['lexicon', { backfill: 'false' }, 'backfill'], + ['lexicon', { target_collection: 123 }, 'target_collection'], + ['lexicon', { action: false }, 'action'], + ['lexicon', { token_cost: '2' }, 'token_cost'], + ['script', { script_type: 123 }, 'script_type'], + ['script', { description: false }, 'description'], + ]) { + await writeFile(moduleFile, JSON.stringify({ assets: [{ id: 'org.example.asset', kind, path: 'missing', config }] })); + await assert.rejects(() => loadAssets(manifest), (error) => + /Module shared\/manifest\.json.*assets\[0\]/.test(error.message) && error.message.includes(field)); + } +}); + +test('token_cost must be a signed 32-bit integer before loading its source or calling admin', async (t) => { + const manifest = await bundle(t, { shared: { assets: [] } }); + const moduleFile = path.join(path.dirname(manifest), 'shared/manifest.json'); + let adminCalls = 0; + for (const tokenCost of [1.5, 2147483648, -2147483649]) { + await writeFile(moduleFile, JSON.stringify({ assets: [{ + id: 'org.example.asset', kind: 'lexicon', path: 'missing', config: { token_cost: tokenCost }, + }] })); + await assert.rejects(async () => { + const { assets } = await loadAssets(manifest); + await applyAssets(assets, { + read: async () => { adminCalls++; return null; }, + write: async () => { adminCalls++; }, + }); + }, /Module shared\/manifest\.json.*assets\[0\].*config\.token_cost.*signed 32-bit integer/i); + } + assert.equal(adminCalls, 0); +}); + +test('token_cost accepts the signed 32-bit integer boundaries', async (t) => { + const manifest = await bundle(t, { shared: { + assets: [], files: { 'schema.json': '{"id":"org.example.asset"}' }, + } }); + const moduleFile = path.join(path.dirname(manifest), 'shared/manifest.json'); + for (const tokenCost of [-2147483648, 2147483647]) { + await writeFile(moduleFile, JSON.stringify({ assets: [{ + id: 'org.example.asset', kind: 'lexicon', path: 'schema.json', config: { token_cost: tokenCost }, + }] })); + const { assets } = await loadAssets(manifest); + assert.equal(assets[0].config.token_cost, tokenCost); + } +}); + +test('null module manifest identifies the module and corrective action', async (t) => { + const manifest = await bundle(t, { shared: { assets: [] } }); + await writeFile(path.join(path.dirname(manifest), 'shared/manifest.json'), 'null'); + await assert.rejects(() => loadAssets(manifest), /Module shared\/manifest.json.*assets.*fix|Module shared\/manifest.json.*assets.*check/i); +}); + +test('a non-array assets field throws a type error with the existing repair message', async (t) => { + const manifest = await bundle(t, { shared: { assets: [] } }); + const moduleFile = path.join(path.dirname(manifest), 'shared/manifest.json'); + await writeFile(moduleFile, JSON.stringify({ assets: {} })); + + await assert.rejects(() => loadAssets(manifest), (error) => { + assert.ok(error instanceof TypeError); + assert.equal(error.message, 'Module shared/manifest.json must declare an assets array; fix its manifest before installing'); + return true; + }); +}); + +test('invalid asset entries and IDs identify the module and asset index', async (t) => { + const manifest = await bundle(t, { shared: { assets: [] } }); + const moduleFile = path.join(path.dirname(manifest), 'shared/manifest.json'); + for (const entry of [null, { id: '' }, { id: ' ' }, { id: 3 }, { id: 'valid', kind: 'unknown' }]) { + await writeFile(moduleFile, JSON.stringify({ assets: [entry] })); + await assert.rejects(() => loadAssets(manifest), /Module shared\/manifest.json.*assets\[0\].*id.*kind.*fix/i); + } +}); + +test('dependsOn must be an array of nonempty string asset IDs', async (t) => { + const manifest = await bundle(t, { shared: { + assets: [{ id: 'shared.schema', kind: 'lexicon', path: 'schema.json', config: {} }], files: { 'schema.json': '{"id":"shared.schema"}' }, + } }); + const moduleFile = path.join(path.dirname(manifest), 'shared/manifest.json'); + for (const dependsOn of ['shared.schema', null, [null], [''], [42]]) { + await writeFile(moduleFile, JSON.stringify({ assets: [{ id: 'shared.schema', kind: 'lexicon', path: 'schema.json', config: {}, dependsOn }] })); + await assert.rejects(() => loadAssets(manifest), /Module shared\/manifest.json.*assets\[0\].*dependsOn.*array.*nonempty.*fix/i); + } +}); + +test('asset config must be a non-array object before any admin calls', async (t) => { + for (const [name, config] of [['absent', undefined], ['null', null], ['array', []], ['string', 'invalid'], ['number', 7]]) { + const asset = { id: `org.example.${name}`, kind: 'lexicon', path: 'schema.json' }; + if (config !== undefined) asset.config = config; + const manifest = await bundle(t, { shared: { + assets: [asset], files: { 'schema.json': JSON.stringify({ id: asset.id }) }, + } }); + let adminCalls = 0; + await assert.rejects(async () => { + const { assets } = await loadAssets(manifest); + await applyAssets(assets, { + read: async () => { adminCalls++; return null; }, + write: async () => { adminCalls++; }, + }); + }, /Module shared\/manifest.json.*assets\[0\].*config.*non-array object.*fix/i); + assert.equal(adminCalls, 0, `${name} config must fail before admin calls`); + } +}); + +test('schema-only bundle installs without location handlers or script assets', async (t) => { + const manifest = await bundle(t, { shared: { + assets: [{ id: 'org.example.shared', kind: 'lexicon', path: 'schema.json', config: { backfill: false } }], + files: { 'schema.json': '{"lexicon":1,"id":"org.example.shared"}' }, + } }); + const { assets } = await loadAssets(manifest); + const admin = client(); + assert.deepEqual(await applyAssets(assets, admin), { changed: ['org.example.shared'], unchanged: [] }); + assert.deepEqual(assets[0].lexicon_json, { lexicon: 1, id: 'org.example.shared' }); +}); + +test('lexicon source ID must match the declared asset ID', async (t) => { + const manifest = await bundle(t, { shared: { + assets: [{ id: 'org.example.declared', kind: 'lexicon', path: 'schema.json', config: {} }], + files: { 'schema.json': '{"id":"org.example.different"}' }, + } }); + await assert.rejects(() => loadAssets(manifest), /Asset org\.example\.declared.*lexicon ID org\.example\.different.*does not match/i); +}); + +test('composes two domains and one shared owner with cross-module ordering and module-relative sources', async (t) => { + const manifest = await bundle(t, { + alpha: { assets: [ + { id: 'alpha.script', kind: 'script', path: 'handler.lua', config: {}, dependsOn: ['beta.schema'] }, + ], files: { 'handler.lua': 'return "alpha"' } }, + beta: { assets: [ + { id: 'beta.schema', kind: 'lexicon', path: 'schema.json', config: {}, dependsOn: ['shared.schema'] }, + ], files: { 'schema.json': '{"id":"beta.schema"}' } }, + shared: { assets: [ + { id: 'shared.schema', kind: 'lexicon', path: 'schema.json', config: {} }, + ], files: { 'schema.json': '{"id":"shared.schema"}' } }, + }); + const { assets } = await loadAssets(manifest); + assert.equal(assets.find(({ id }) => id === 'alpha.script').body, 'return "alpha"'); + const admin = client(); + await applyAssets(assets, admin); + assert.deepEqual(admin.writes, ['shared.schema', 'beta.schema', 'alpha.script']); +}); + +test('missing, non-file or empty script sources fail during loading before admin calls', async (t) => { + for (const [name, files] of [['missing', {}], ['empty', { 'handler.lua': ' \n ' }], ['directory', { 'handler.lua/child': 'content' }]]) { + const manifest = await bundle(t, { [name]: { assets: [ + { id: `${name}.script`, kind: 'script', path: 'handler.lua', config: {} }, + ], files } }); + await assert.rejects(() => loadAssets(manifest), (error) => + error.message.includes(`${name}.script`) && error.message.includes(name) && /source|empty|missing/i.test(error.message)); + } +}); + +test('missing module manifest identifies the module and repair action', async (t) => { + const manifest = await bundle(t, { shared: { assets: [] } }); + await rm(path.join(path.dirname(manifest), 'shared/manifest.json')); + await assert.rejects(() => loadAssets(manifest), /Module shared\/manifest.json.*missing.*check.*module/i); +}); + +test('packagePath Lexicons still load from the installed package', async (t) => { + const manifest = await bundle(t, { shared: { assets: [ + { id: 'org.hypercerts.defs', kind: 'lexicon', packagePath: 'lexicons/org/hypercerts/defs.json', config: { backfill: false } }, + ] } }); + const { assets } = await loadAssets(manifest); + assert.equal(assets[0].lexicon_json.id, 'org.hypercerts.defs'); +}); + +test('duplicate asset IDs across modules are rejected instead of silently selecting one', async (t) => { + const manifest = await bundle(t, { + alpha: { assets: [{ id: 'shared.schema', kind: 'lexicon', path: 'schema.json', config: {} }], files: { 'schema.json': '{"id":"shared.schema"}' } }, + beta: { assets: [{ id: 'shared.schema', kind: 'lexicon', path: 'schema.json', config: {} }], files: { 'schema.json': '{"id":"shared.schema"}' } }, + }); + await assert.rejects(() => loadAssets(manifest), /Duplicate asset shared\.schema.*alpha.*beta/); +}); + +test('missing dependencies and cross-module cycles refuse the combined bundle before reads or writes', async (t) => { + for (const [name, dependency, other] of [ + ['missing', 'absent', []], + ['cycle', 'beta.schema', [{ id: 'beta.schema', kind: 'lexicon', path: 'schema.json', config: {}, dependsOn: ['alpha.schema'] }]], + ]) { + const manifest = await bundle(t, { + alpha: { assets: [{ id: 'alpha.schema', kind: 'lexicon', path: 'schema.json', config: {}, dependsOn: [dependency] }], files: { 'schema.json': '{"id":"alpha.schema"}' } }, + beta: { assets: other, files: { 'schema.json': '{"id":"beta.schema"}' } }, + }); + await assert.rejects(() => loadAssets(manifest), name === 'missing' ? /Missing asset dependency.*absent/ : /cycle/); + } +}); + +test('conflict in later module prevents writes in earlier modules', async (t) => { + const manifest = await bundle(t, { + first: { assets: [{ id: 'first.schema', kind: 'lexicon', path: 'schema.json', config: { backfill: false } }], files: { 'schema.json': '{"id":"first.schema"}' } }, + later: { assets: [{ id: 'later.schema', kind: 'lexicon', path: 'schema.json', config: { backfill: false } }], files: { 'schema.json': '{"id":"later.schema"}' } }, + }); + const { assets } = await loadAssets(manifest); + const admin = client({ 'later.schema': { config: { backfill: true }, lexicon_json: {} } }); + await assert.rejects(() => applyAssets(assets, admin), /Refusing later.schema/); + assert.deepEqual(admin.reads, ['first.schema', 'later.schema']); + assert.deepEqual(admin.writes, []); +}); diff --git a/hypercerts-api/tooling/installer-cli.test.js b/hypercerts-api/tooling/installer-cli.test.js new file mode 100644 index 00000000..979faecf --- /dev/null +++ b/hypercerts-api/tooling/installer-cli.test.js @@ -0,0 +1,65 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { copyFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +test('CLI requires a nonblank admin token and does not fall back to a session cookie', () => { + const script = fileURLToPath(new URL('./installer.js', import.meta.url)); + for (const token of [undefined, '', ' \t']) { + const env = { + PATH: process.env.PATH ?? '', + HAPPYVIEW_BASE_URL: 'http://127.0.0.1:8080', + HAPPYVIEW_SESSION_COOKIE: 'session=legacy-secret', + }; + if (token !== undefined) env.HAPPYVIEW_ADMIN_TOKEN = token; + const child = spawnSync(process.execPath, [script], { encoding: 'utf8', env }); + assert.equal(child.status, 1); + assert.match(child.stderr, /HAPPYVIEW_ADMIN_TOKEN.*README/); + assert.doesNotMatch(child.stderr, /legacy-secret|manifest\.json/); + } +}); + +test('importing the installer does not run the CLI or require configuration', () => { + const script = fileURLToPath(new URL('./installer.js', import.meta.url)); + const child = spawnSync(process.execPath, ['--input-type=module', '-e', 'await import(process.argv[1])', pathToFileURL(script).href], { + encoding: 'utf8', + env: { PATH: process.env.PATH ?? '' }, + }); + assert.equal(child.status, 0, child.stderr); + assert.equal(child.stdout, ''); + assert.equal(child.stderr, ''); +}); + +test('CLI main-module detection works when the checkout path contains #', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'happyview#installer-cli-')); + try { + const tooling = path.join(root, 'tooling'); + const shared = path.join(root, 'shared'); + await mkdir(tooling); + await mkdir(shared); + const script = path.join(tooling, 'installer.js'); + await copyFile(fileURLToPath(new URL('./installer.js', import.meta.url)), script); + await writeFile(path.join(tooling, 'lexicon-source.js'), 'export async function readLexiconSource() { return {}; }'); + await writeFile(path.join(root, 'manifest.json'), JSON.stringify({ modules: ['shared/manifest.json'] })); + await writeFile(path.join(shared, 'manifest.json'), JSON.stringify({ + assets: [{ id: 'org.example.missing', kind: 'script', config: { script_type: 'query' }, path: 'missing.lua' }], + })); + + const child = spawnSync(process.execPath, [script], { + encoding: 'utf8', + env: { + PATH: process.env.PATH ?? '', + HAPPYVIEW_BASE_URL: 'http://127.0.0.1:8000', + HAPPYVIEW_ADMIN_TOKEN: 'hv_cli-test-token', + }, + }); + assert.equal(child.status, 1); + assert.match(child.stderr, /source missing\.lua is missing/); + assert.doesNotMatch(child.stderr, /HAPPYVIEW_(BASE_URL|ADMIN_TOKEN) is required/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/hypercerts-api/tooling/installer-core.test.js b/hypercerts-api/tooling/installer-core.test.js new file mode 100644 index 00000000..c95bf489 --- /dev/null +++ b/hypercerts-api/tooling/installer-core.test.js @@ -0,0 +1,252 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { applyAssets, compareAsset, createAdminClient, orderAssets, sortJsonKeys } from './installer.js'; + +test('installer orders dependencies before consumers regardless of manifest order', () => { + const assets = [ + { id: 'script', kind: 'script', dependsOn: ['query'] }, + { id: 'query', kind: 'lexicon', dependsOn: ['record'] }, + { id: 'record', kind: 'lexicon' }, + ]; + assert.deepEqual(orderAssets(assets).map(({ id }) => id), ['record', 'query', 'script']); + assert.throws(() => orderAssets([{ id: 'a', dependsOn: ['b'] }, { id: 'b', dependsOn: ['a'] }]), /cycle.*remove.*dependsOn/i); + assert.throws(() => orderAssets([{ id: 'a', dependsOn: ['missing'] }]), /Missing asset dependency.*add.*bundle/i); +}); + +test('installer preflights every asset before writing and refuses conflicts', async () => { + const written = []; + await assert.rejects(() => applyAssets([ + { id: 'first', kind: 'lexicon', config: { backfill: false } }, + { id: 'second', kind: 'lexicon', config: { backfill: false } }, + ], { + read: async ({ id }) => id === 'first' ? null : { config: { backfill: true } }, + write: async ({ id }) => written.push(id), + }), /unexpected installed difference/); + assert.deepEqual(written, []); +}); + +test('unchanged assets are skipped and partial failures report completed and remaining writes', async () => { + const assets = [ + { id: 'existing', kind: 'script', config: {}, body: 'return true' }, + { id: 'new', kind: 'script', config: {}, body: 'return true' }, + { id: 'failed', kind: 'script', config: {}, body: 'return true' }, + { id: 'later', kind: 'script', config: {}, body: 'return true' }, + ]; + const written = []; + await assert.rejects(() => applyAssets(assets, { + read: async ({ id }) => id === 'existing' ? { config: {}, body: 'return true' } : null, + write: async ({ id }) => { if (id === 'failed') throw new Error('disk full'); written.push(id); }, + }), (error) => { + assert.deepEqual(error.completed, ['new']); + assert.deepEqual(error.remaining, ['failed', 'later']); + return /disk full/.test(error.message); + }); + assert.deepEqual(written, ['new']); +}); + +test('apply reports an all-unchanged result without writing', async () => { + const asset = { id: 'same', kind: 'script', config: {}, body: 'return true' }; + let writes = 0; + const result = await applyAssets([asset], { + read: async () => ({ config: {}, body: 'return true' }), + write: async () => { writes++; }, + }); + assert.equal(writes, 0); + assert.deepEqual(result, { changed: [], unchanged: ['same'] }); +}); + +test('admin client requires a nonblank bearer token and rejects cookie-only auth', () => { + let called = false; + const fetchImpl = async () => { called = true; }; + for (const token of [undefined, '', ' \t']) { + assert.throws(() => createAdminClient({ + baseUrl: 'http://localhost:8080', token, cookie: 'session=secret', fetchImpl, + }), /HAPPYVIEW_ADMIN_TOKEN.*README/); + } + assert.throws(() => createAdminClient({ + baseUrl: 'http://localhost:8080', cookie: 'session=secret', fetchImpl, + }), /HAPPYVIEW_ADMIN_TOKEN.*README/); + assert.equal(called, false); +}); + +test('admin client sanitizes request failures that contain the bearer token', async () => { + const token = 'hv_do-not-leak'; + const admin = createAdminClient({ + baseUrl: 'http://localhost:8080', token, + fetchImpl: async () => { throw new Error(`request failed for ${token}`); }, + }); + await assert.rejects(() => admin.read({ id: 'x', kind: 'script' }), (error) => { + assert.match(error.message, /request failed before receiving a response/); + assert.doesNotMatch(error.message, new RegExp(token)); + return true; + }); +}); + +test('admin client rejects redirects before forwarding the bearer token', async (t) => { + const token = 'hv_redirect-secret'; + const paths = []; + const server = createServer((request, response) => { + paths.push(request.url); + if (request.url === '/admin/scripts/x') { + response.writeHead(302, { location: '/redirect-target' }); + response.end(); + } else { + response.end('{}'); + } + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))); + const { port } = server.address(); + const admin = createAdminClient({ baseUrl: `http://127.0.0.1:${port}`, token }); + + await assert.rejects(() => admin.read({ id: 'x', kind: 'script' }), /request failed before receiving a response/); + assert.deepEqual(paths, ['/admin/scripts/x']); +}); + +test('admin client rejects unsafe URLs before sending a request or exposing URL details', () => { + let called = false; + const fetchImpl = async () => { called = true; }; + for (const baseUrl of [ + 'http://example.com', 'http://localhost.attacker.test', 'http://127.0.0.1.attacker.test', + 'file:///tmp/test', 'ftp://example.com', 'https://user:password@example.com', + ]) { + assert.throws(() => createAdminClient({ baseUrl, token: 'hv_private-secret', fetchImpl }), (error) => { + assert.doesNotMatch(error.message, /private-secret|password|example\.com/); + return true; + }, baseUrl); + } + assert.equal(called, false); +}); + +test('admin client accepts loopback HTTP and HTTPS targets', async () => { + for (const baseUrl of ['http://localhost:8000', 'http://127.0.0.1:8000', 'http://[::1]:8000', 'https://admin.example.com']) { + let called = false; + const admin = createAdminClient({ + baseUrl, token: 'hv_loopback-test-token', + fetchImpl: async () => { called = true; return new Response(null, { status: 404 }); }, + }); + assert.equal(await admin.read({ id: 'schema', kind: 'lexicon' }), null); + assert.equal(called, true, baseUrl); + } +}); + +test('admin client treats only GET 404 as missing and sanitizes HTTP errors', async () => { + const responses = [ + new Response(null, { status: 404 }), + new Response(null, { status: 404 }), + new Response('hv_http-test-secret internal detail', { status: 500 }), + ]; + const calls = []; + const admin = createAdminClient({ + baseUrl: 'http://127.0.0.1:8000', token: 'hv_http-test-secret', + fetchImpl: async (_url, init) => { calls.push(init); return responses.shift(); }, + }); + assert.equal(await admin.read({ id: 'schema', kind: 'lexicon' }), null); + await assert.rejects(() => admin.write({ id: 'schema', kind: 'lexicon', config: {}, lexicon_json: {} }), /POST .*HTTP 404/); + await assert.rejects(() => admin.read({ id: 'schema', kind: 'lexicon' }), (error) => { + assert.match(error.message, /GET .*HTTP 500/); + assert.doesNotMatch(error.message, /hv_http-test-secret|internal detail/); + return true; + }); + assert.deepEqual(calls.map(({ method }) => method), ['GET', 'POST', 'GET']); +}); + +test('semantic JSON comparison ignores nested key order and canonical Lexicon refs, but rejects changed values and script bodies', () => { + const item = { + id: 'org.example.record', kind: 'lexicon', config: { backfill: false }, + lexicon_json: { + lexicon: 1, + id: 'org.example.record', + defs: { + main: { + type: 'record', + record: { + type: 'object', + properties: { + view: { type: 'ref', ref: 'org.example.defs#view' }, + choices: { type: 'union', refs: ['org.example.defs#one', 'org.example.defs#two'] }, + }, + }, + }, + }, + }, + }; + const installed = { + config: { backfill: false }, + lexicon_json: { + defs: { + main: { + record: { + properties: { + choices: { refs: ['lex:org.example.defs#one', 'lex:org.example.defs#two'], type: 'union' }, + view: { ref: 'lex:org.example.defs#view', type: 'ref' }, + }, + type: 'object', + }, + type: 'record', + }, + }, + id: 'org.example.record', + lexicon: 1, + }, + }; + assert.equal(compareAsset(item, installed), 'unchanged'); + assert.equal(item.lexicon_json.defs.main.record.properties.view.ref, 'org.example.defs#view'); + + const changedLexicon = structuredClone(installed.lexicon_json); + changedLexicon.defs.main.record.properties.view.ref = 'lex:org.example.other#view'; + assert.equal(compareAsset(item, { ...installed, lexicon_json: changedLexicon }), 'conflict'); + assert.equal(compareAsset({ id: 'script', kind: 'script', config: {}, body: 'return true\n' }, { config: {}, body: 'return true' }), 'conflict'); +}); + +test('asset comparison ignores server defaults for undeclared config keys', () => { + const asset = { id: 'script', kind: 'script', config: {}, body: 'return true' }; + assert.equal(compareAsset(asset, { config: { script_type: 'lua' }, body: 'return true' }), 'unchanged'); + + const configured = { ...asset, config: { description: 'custom' } }; + assert.equal(compareAsset(configured, { config: { script_type: 'lua', description: 'custom' }, body: 'return true' }), 'unchanged'); + assert.equal(compareAsset(configured, { config: { script_type: 'lua', description: 'different' }, body: 'return true' }), 'conflict'); +}); + +test('semantic JSON sorts nested keys by UTF-16 code units rather than locale collation', () => { + const input = { + '\uE000': { a: 1, Z: 2 }, + '\u{10000}': { '\uE000': 1, '\u{10000}': 2 }, + é: 3, + a: 4, + Z: 5, + }; + assert.equal( + JSON.stringify(sortJsonKeys(input)), + '{"Z":5,"a":4,"é":3,"\u{10000}":{"\u{10000}":2,"\uE000":1},"\uE000":{"Z":2,"a":1}}', + ); +}); + +test('admin client preserves action and token_cost when writing lexicons', async () => { + const calls = []; + const admin = createAdminClient({ + baseUrl: 'http://localhost:8080', + token: 'hv_write-secret', + fetchImpl: async (url, options) => { + calls.push({ url: String(url), options }); + return { ok: true, status: 200, json: async () => ({}) }; + }, + }); + await admin.write({ + id: 'org.example.record', + kind: 'lexicon', + config: { backfill: true, target_collection: 'org.example.record', action: 'create', token_cost: 7 }, + lexicon_json: { lexicon: 1, id: 'org.example.record' }, + }); + assert.equal(calls[0].url, 'http://localhost:8080/admin/lexicons'); + assert.equal(calls[0].options.headers.Authorization, 'Bearer hv_write-secret'); + assert.equal('cookie' in calls[0].options.headers, false); + assert.deepEqual(JSON.parse(calls[0].options.body), { + lexicon_json: { lexicon: 1, id: 'org.example.record' }, + backfill: true, + target_collection: 'org.example.record', + action: 'create', + token_cost: 7, + }); +}); diff --git a/hypercerts-api/tooling/installer.js b/hypercerts-api/tooling/installer.js new file mode 100644 index 00000000..8f54c123 --- /dev/null +++ b/hypercerts-api/tooling/installer.js @@ -0,0 +1,456 @@ +import { readFile, stat } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { readLexiconSource } from './lexicon-source.js'; + +/** @typedef {Record & { backfill?: boolean; target_collection?: string; action?: string; token_cost?: number }} LexiconAssetConfig */ +/** @typedef {Record & { script_type?: string; description?: string }} ScriptAssetConfig */ +/** @typedef {LexiconAssetConfig | ScriptAssetConfig} AssetConfig */ +/** @typedef {Record & { id: string; kind: 'lexicon' | 'script' }} AssetCandidate */ +/** @typedef {Record & { modules: string[] }} BundleManifest */ +/** @typedef {Record & { id: string; kind: 'lexicon'; config: LexiconAssetConfig; dependsOn?: string[]; path?: string; packagePath?: string }} LexiconManifestAsset */ +/** @typedef {Record & { id: string; kind: 'script'; config: ScriptAssetConfig; dependsOn?: string[]; path?: string }} ScriptManifestAsset */ +/** @typedef {LexiconManifestAsset | ScriptManifestAsset} ValidatedManifestAsset */ +/** @typedef {Record & { assets: ValidatedManifestAsset[] }} ModuleManifest */ +/** @typedef {LexiconManifestAsset & { lexicon_json: unknown }} LoadedLexiconAsset */ +/** @typedef {ScriptManifestAsset & { path: string; body: string }} LoadedScriptAsset */ +/** @typedef {LoadedLexiconAsset | LoadedScriptAsset} LoadedAsset */ +/** @typedef {{ id: string; kind?: 'lexicon' | 'script'; dependsOn?: string[] }} OrderableAsset */ +/** @typedef {'missing' | 'unchanged'} InstallState */ +/** @typedef {{ asset: LoadedAsset; state: InstallState }} AssetInstallState */ +/** @typedef {{ config?: AssetConfig | null; lexicon_json?: unknown; body?: unknown }} InstalledAsset */ +/** External GET /admin/lexicons/:id response assertion; response.json() is not runtime-validated. @typedef {{ backfill: boolean; target_collection: string | null; action: string | null; token_cost: number | null; lexicon_json: unknown }} LexiconAdminRow */ +/** External GET /admin/scripts/:id response assertion; response.json() is not runtime-validated. @typedef {{ script_type: string; description: string | null; body: string }} ScriptAdminRow */ +/** @typedef {{ read: (asset: LoadedAsset) => Promise; write: (asset: LoadedAsset) => Promise }} AdminClient */ +/** @typedef {{ changed: string[]; unchanged: string[] }} InstallResult */ +/** @typedef {Error & { completed: string[]; remaining: string[] }} PartialInstallError */ + +const require = createRequire(new URL('../package.json', import.meta.url)); + +/** @param {unknown} value @returns {unknown} */ +export function sortJsonKeys(value) { + if (Array.isArray(value)) return value.map(sortJsonKeys); + if (value && typeof value === 'object') { + const record = /** @type {Record} */ (value); + return Object.fromEntries(Object.keys(value).sort((a, b) => { + if (a < b) return -1; + if (a > b) return 1; + return 0; + }).map((key) => [key, sortJsonKeys(record[key])])); + } + return value; +} + +/** @param {unknown} document @returns {unknown} */ +function canonicalizeLexiconRefs(document) { + // HappyView stores canonical `lex:` refs; package schemas may use unprefixed refs. + const { Lexicons } = require('@atproto/lexicon'); + // Lexicons performs the existing runtime document validation at this boundary. + const canonical = /** @type {import('@atproto/lexicon').LexiconDoc} */ (structuredClone(document)); + const lexicons = new Lexicons([canonical]); + return lexicons.get(canonical.id); +} + +/** @param {LoadedAsset} asset @param {InstalledAsset | null} installed @returns {'missing' | 'conflict' | 'unchanged'} */ +export function compareAsset(asset, installed) { + if (installed == null) return 'missing'; + const declaredConfig = asset.config ?? {}; + const installedConfig = Object.fromEntries(Object.keys(declaredConfig).map((key) => [key, installed.config?.[key]])); + if (JSON.stringify(sortJsonKeys(installedConfig)) !== JSON.stringify(sortJsonKeys(declaredConfig))) return 'conflict'; + if (asset.kind === 'lexicon') { + if (installed.lexicon_json == null) return 'conflict'; + const installedLexicon = canonicalizeLexiconRefs(installed.lexicon_json); + const declaredLexicon = canonicalizeLexiconRefs(asset.lexicon_json); + if (JSON.stringify(sortJsonKeys(installedLexicon)) !== JSON.stringify(sortJsonKeys(declaredLexicon))) return 'conflict'; + } + if (asset.kind === 'script' && installed.body !== asset.body) return 'conflict'; + return 'unchanged'; +} + +/** @param {string | URL} baseUrl @returns {URL} */ +function validateAdminUrl(baseUrl) { + let target; + try { + target = new URL(baseUrl); + } catch { + throw new Error('HappyView admin URL must be a valid HTTP(S) URL'); + } + const IPV6_ADDRESS_BRACKETS = /^\[|\]$/g; + const hostname = target.hostname.toLowerCase().replace(IPV6_ADDRESS_BRACKETS, ''); + const loopback = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; + if (!['http:', 'https:'].includes(target.protocol) || target.username || target.password || (target.protocol === 'http:' && !loopback)) { + throw new Error('HappyView admin URL must use HTTPS, or HTTP on localhost/127.0.0.1/::1, with no URL credentials'); + } + return target; +} + +/** @param {URL} target @param {string} token @param {typeof globalThis.fetch} fetchImpl */ +function createAdminRequest(target, token, fetchImpl) { + return /** @param {'GET' | 'POST'} method @param {string} route @param {unknown} [body] */ async function request(method, route, body) { + let response; + try { + response = await fetchImpl(new URL(route, target), { + method, + headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }) }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + redirect: 'error', + }); + } catch { + throw new Error(`HappyView ${method} ${route} request failed before receiving a response`); + } + if (method === 'GET' && response.status === 404) return null; + if (!response.ok) throw new Error(`HappyView ${method} ${route} returned HTTP ${response.status}`); + return response.status === 204 ? null : response.json(); + }; +} + +/** @param {{ baseUrl: string | URL; token: string; fetchImpl?: typeof globalThis.fetch }} options */ +export function createAdminClient({ baseUrl, token, fetchImpl = globalThis.fetch }) { + if (typeof token !== 'string' || !token.trim()) { + throw new Error('HAPPYVIEW_ADMIN_TOKEN is required and must not be blank; set it before running the installer (see hypercerts-api/README.md)'); + } + const target = validateAdminUrl(baseUrl); + const request = createAdminRequest(target, token.trim(), fetchImpl); + return { + /** @param {LoadedAsset} asset */ + async read(asset) { + const encoded = encodeURIComponent(asset.id); + const row = await request('GET', asset.kind === 'lexicon' ? `/admin/lexicons/${encoded}` : `/admin/scripts/${encoded}`); + if (!row) return null; + if (asset.kind === 'lexicon') { + const lexiconRow = /** @type {LexiconAdminRow} */ (row); + return { config: { backfill: lexiconRow.backfill, target_collection: lexiconRow.target_collection ?? undefined, action: lexiconRow.action ?? undefined, token_cost: lexiconRow.token_cost ?? undefined }, lexicon_json: lexiconRow.lexicon_json }; + } + const scriptRow = /** @type {ScriptAdminRow} */ (row); + return { config: { script_type: scriptRow.script_type, description: scriptRow.description ?? undefined }, body: scriptRow.body }; + }, + /** @param {LoadedAsset} asset */ + async write(asset) { + if (asset.kind === 'lexicon') { + await request('POST', '/admin/lexicons', { + lexicon_json: asset.lexicon_json, + backfill: asset.config.backfill, + target_collection: asset.config.target_collection, + action: asset.config.action, + token_cost: asset.config.token_cost, + }); + } else { + await request('POST', '/admin/scripts', { id: asset.id, script_type: asset.config.script_type, description: asset.config.description, body: asset.body }); + } + }, + }; +} + +/** + * @template {OrderableAsset} T + * @param {T[]} assets + * @returns {T[]} + */ +export function orderAssets(assets) { + /** @type {Map} */ + const byId = new Map(); + for (const asset of assets) { + if (byId.has(asset.id)) throw new Error(`Duplicate asset ${asset.id}; declare each asset in one module only`); + byId.set(asset.id, asset); + } + /** @type {T[]} */ + const ordered = []; + const visiting = new Set(); + const visited = new Set(); + /** @param {T} asset */ + function visit(asset) { + if (visited.has(asset.id)) return; + if (visiting.has(asset.id)) throw new Error(`Asset dependency cycle at ${asset.id}; remove the circular dependsOn reference before installing`); + visiting.add(asset.id); + for (const dependency of asset.dependsOn ?? []) { + const parent = byId.get(dependency); + if (!parent) throw new Error(`Missing asset dependency ${dependency} required by ${asset.id}; add its owner module to the bundle before installing`); + visit(parent); + } + visiting.delete(asset.id); + visited.add(asset.id); + ordered.push(asset); + } + for (const asset of [...assets].sort((a, b) => (a.kind === 'lexicon' ? 0 : 1) - (b.kind === 'lexicon' ? 0 : 1))) visit(asset); + return ordered; +} + +/** @param {LoadedAsset[]} ordered @param {AdminClient} client @returns {Promise} */ +async function preflightAssets(ordered, client) { + const states = []; + for (const asset of ordered) { + const installed = await client.read(asset); + const state = compareAsset(asset, installed); + if (state === 'conflict') throw new Error(`Refusing ${asset.id}: unexpected installed difference; inspect and resolve manually before retrying`); + states.push({ asset, state }); + } + return states; +} + +/** @param {AssetInstallState[]} states @param {AdminClient} client @returns {Promise} */ +async function writeMissingAssets(states, client) { + const changed = []; + for (let i = 0; i < states.length; i++) { + const { asset, state } = states[i]; + if (state === 'unchanged') continue; + try { + await client.write(asset); + changed.push(asset.id); + } catch (cause) { + const reason = cause instanceof Error ? cause.message : 'unknown write failure'; + const error = /** @type {PartialInstallError} */ (new Error(`Install partially failed at ${asset.id}: ${reason}; completed: ${changed.join(', ') || '(none)'}; remaining: ${states.slice(i).filter((entry) => entry.state !== 'unchanged').map((entry) => entry.asset.id).join(', ')}`, { cause })); + error.completed = changed; + error.remaining = states.slice(i).filter((entry) => entry.state !== 'unchanged').map((entry) => entry.asset.id); + throw error; + } + } + return { changed, unchanged: states.filter((entry) => entry.state === 'unchanged').map(({ asset }) => asset.id) }; +} + +/** @param {LoadedAsset[]} assets @param {AdminClient} client @returns {Promise} */ +export async function applyAssets(assets, client) { + const states = await preflightAssets(orderAssets(assets), client); + return writeMissingAssets(states, client); +} + +// Root manifest (paths are relative to this file): +// { "modules": ["modules/shared/manifest.json", "modules/location/manifest.json"] } +/** @param {string} manifestPath @returns {Promise} */ +async function readBundleManifest(manifestPath) { + let parsed; + try { + parsed = JSON.parse(await readFile(manifestPath, 'utf8')); + } catch (cause) { + // readFile and JSON.parse throw Error instances; preserve their original message interpolation. + const detail = /** @type {Error} */ (cause).message; + throw new Error(`Bundle ${manifestPath} is missing or invalid (${detail}); create or fix the root manifest before installing`, { cause }); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Bundle ${manifestPath} must list at least one module manifest in modules; add a module before installing`); + } + const candidate = /** @type {Record} */ (parsed); + if (!Array.isArray(candidate.modules) || candidate.modules.length === 0) { + throw new Error(`Bundle ${manifestPath} must list at least one module manifest in modules; add a module before installing`); + } + /** @type {string[]} */ + const modules = []; + for (const [moduleIndex, modulePath] of candidate.modules.entries()) { + if (typeof modulePath !== 'string' || !modulePath.trim()) { + throw new Error(`Bundle ${manifestPath} modules[${moduleIndex}] must be a nonempty module path; fix the modules list before installing`); + } + modules.push(modulePath); + } + return { ...candidate, modules }; +} + +// Module manifest (asset paths are relative to this module manifest): +// { +// "assets": [ +// { "id": "org.example.schema", "kind": "lexicon", "path": "schema.json", "config": { "backfill": false } }, +// { "id": "org.example.handler", "kind": "script", "path": "handler.lua", "config": { "script_type": "lua" }, "dependsOn": ["org.example.schema"] } +// ] +// } +/** @param {string} modulePath @param {string} file @param {Map} owners @returns {Promise} */ +async function readModuleManifest(modulePath, file, owners) { + let parsed; + try { + parsed = JSON.parse(await readFile(file, 'utf8')); + } catch (cause) { + // readFile and JSON.parse throw Error instances; preserve their original message interpolation. + const detail = /** @type {Error} */ (cause).message; + throw new Error(`Module ${modulePath} is missing or invalid (${detail}); check the bundle's modules list and module manifest`, { cause }); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Module ${modulePath} must declare an assets array; fix its manifest before installing`); + } + const candidate = /** @type {Record} */ (parsed); + if (!Array.isArray(candidate.assets)) { + throw new TypeError(`Module ${modulePath} must declare an assets array; fix its manifest before installing`); + } + /** @type {ValidatedManifestAsset[]} */ + const assets = []; + for (const [assetIndex, entry] of candidate.assets.entries()) { + assets.push(validateAssetEntry(entry, modulePath, assetIndex, owners)); + } + return { ...candidate, assets }; +} + +/** @param {unknown} entry @param {string} modulePath @param {number} assetIndex @returns {AssetCandidate} */ +function validateAssetShape(entry, modulePath, assetIndex) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw new Error(`Module ${modulePath} assets[${assetIndex}] needs a nonempty string id and kind lexicon or script; fix this asset before installing`); + } + const candidate = /** @type {Record} */ (entry); + if (typeof candidate.id !== 'string' || !candidate.id.trim() || (candidate.kind !== 'lexicon' && candidate.kind !== 'script')) { + throw new Error(`Module ${modulePath} assets[${assetIndex}] needs a nonempty string id and kind lexicon or script; fix this asset before installing`); + } + return /** @type {AssetCandidate} */ (candidate); +} + +/** @param {AssetCandidate} candidate @param {string} modulePath @param {number} assetIndex */ +function validateAssetDependencies(candidate, modulePath, assetIndex) { + if (candidate.dependsOn !== undefined && (!Array.isArray(candidate.dependsOn) || candidate.dependsOn.some((id) => typeof id !== 'string' || !id.trim()))) { + throw new Error(`Module ${modulePath} assets[${assetIndex}] (${candidate.id}) dependsOn must be an array of nonempty string asset IDs; fix its dependencies before installing`); + } +} + +/** @param {string} id @param {string} modulePath @param {Map} owners */ +function claimAssetOwnership(id, modulePath, owners) { + if (owners.has(id)) { + throw new Error(`Duplicate asset ${id} in modules ${owners.get(id)} and ${modulePath}; declare it in one owner only`); + } + owners.set(id, modulePath); +} + +/** @param {string} field @param {unknown} value @param {string} expectedType @returns {boolean} */ +function isValidConfigValue(field, value, expectedType) { + return field === 'token_cost' + ? typeof value === 'number' && Number.isInteger(value) && value >= -2147483648 && value <= 2147483647 + : typeof value === expectedType; +} + +/** @param {AssetCandidate} candidate @param {string} modulePath @param {number} assetIndex */ +function validateAssetConfig(candidate, modulePath, assetIndex) { + if (!candidate.config || typeof candidate.config !== 'object' || Array.isArray(candidate.config)) { + throw new Error(`Module ${modulePath} assets[${assetIndex}] (${candidate.id}) config must be a non-array object; fix its configuration before installing`); + } + const config = /** @type {Record} */ (candidate.config); + const configFieldTypes = candidate.kind === 'lexicon' + ? { backfill: 'boolean', target_collection: 'string', action: 'string', token_cost: 'number' } + : { script_type: 'string', description: 'string' }; + for (const [field, expectedType] of Object.entries(configFieldTypes)) { + const value = config[field]; + const valid = isValidConfigValue(field, value, expectedType); + if (value !== undefined && !valid) { + const expected = field === 'token_cost' ? 'a signed 32-bit integer' : `a ${expectedType}`; + throw new Error(`Module ${modulePath} assets[${assetIndex}] (${candidate.id}) config.${field} must be ${expected} when provided; fix its configuration before installing`); + } + } +} + +/** @param {AssetCandidate} candidate @param {string} modulePath @param {number} assetIndex */ +function validateAssetSourcePaths(candidate, modulePath, assetIndex) { + const sourceFields = candidate.kind === 'lexicon' ? ['path', 'packagePath'] : ['path']; + for (const field of sourceFields) { + const sourcePath = candidate[field]; + if (sourcePath !== undefined && (typeof sourcePath !== 'string' || !sourcePath.trim())) { + throw new Error(`Module ${modulePath} assets[${assetIndex}] (${candidate.id}) ${field} must be a nonempty string path when provided; fix its source declaration before installing`); + } + } +} + +/** @param {unknown} entry @param {string} modulePath @param {number} assetIndex @param {Map} owners @returns {ValidatedManifestAsset} */ +function validateAssetEntry(entry, modulePath, assetIndex, owners) { + const candidate = validateAssetShape(entry, modulePath, assetIndex); + validateAssetDependencies(candidate, modulePath, assetIndex); + claimAssetOwnership(candidate.id, modulePath, owners); + validateAssetConfig(candidate, modulePath, assetIndex); + validateAssetSourcePaths(candidate, modulePath, assetIndex); + return /** @type {ValidatedManifestAsset} */ (candidate); +} + +/* eslint-disable no-param-reassign -- the caller passes a private copy to populate with its loaded source. */ +/** @param {LexiconManifestAsset & { lexicon_json?: unknown }} asset @param {string} root */ +async function loadLexiconAssetSource(asset, root) { + asset.lexicon_json = await readLexiconSource(asset, root); + const lexiconId = /** @type {{ id?: unknown } | null} */ (asset.lexicon_json)?.id; + if (lexiconId !== asset.id) { + const displayedId = typeof lexiconId === 'string' ? lexiconId : JSON.stringify(lexiconId) ?? String(lexiconId); + throw new Error(`lexicon ID ${displayedId} does not match declared asset ID ${asset.id}`); + } +} + +/** @param {ScriptManifestAsset & { body?: string }} asset @param {string} root */ +async function loadScriptAssetSource(asset, root) { + if (!asset.path) throw new Error('script source path is missing'); + const source = path.resolve(root, asset.path); + const info = await stat(source); + if (!info.isFile()) throw new Error('script source is not a file'); + asset.body = await readFile(source, 'utf8'); + if (!asset.body.trim()) throw new Error('script source is empty'); +} +/* eslint-enable no-param-reassign */ + +/** @param {ValidatedManifestAsset} asset @returns {string} */ +function assetSourcePath(asset) { + if (typeof asset.path === 'string') return asset.path; + if (asset.kind === 'lexicon' && typeof asset.packagePath === 'string') return asset.packagePath; + return '(unset)'; +} + +/** @param {ValidatedManifestAsset & { lexicon_json?: unknown; body?: string }} asset @param {string} modulePath @param {string} root @returns {Promise} */ +async function loadAssetSource(asset, modulePath, root) { + try { + if (asset.kind === 'lexicon') { + await loadLexiconAssetSource(asset, root); + } else { + await loadScriptAssetSource(asset, root); + } + } catch (cause) { + // Node filesystem and package-source operations throw Error instances; preserve their original message interpolation. + const detail = /** @type {Error} */ (cause).message; + throw new Error(`Asset ${asset.id} in module ${modulePath}: source ${assetSourcePath(asset)} is missing, invalid or empty (${detail}); fix the declaration or source before installing`, { cause }); + } + return /** @type {LoadedAsset} */ (asset); +} + +/** @param {string} modulePath @param {string} file @param {Map} owners @returns {Promise} */ +async function loadModuleAssets(modulePath, file, owners) { + const module = await readModuleManifest(modulePath, file, owners); + /** @type {LoadedAsset[]} */ + const assets = []; + for (const entry of module.assets) { + const asset = { ...entry }; + assets.push(await loadAssetSource(asset, modulePath, path.dirname(file))); + } + return assets; +} + +/** + * Load one bundle of module manifests, resolving local sources relative to each module. + * Validates all local assets and dependencies before admin calls; returns { assets } for applyAssets. + * @param {string} manifestPath + * @returns {Promise<{ assets: LoadedAsset[] }>} + */ +export async function loadAssets(manifestPath) { + const manifest = await readBundleManifest(manifestPath); + /** @type {LoadedAsset[]} */ + const assets = []; + /** @type {Map} */ + const owners = new Map(); + for (const modulePath of manifest.modules) { + const file = path.resolve(path.dirname(manifestPath), modulePath); + assets.push(...await loadModuleAssets(modulePath, file, owners)); + } + orderAssets(assets); + return { assets }; +} + +/** @param {string} name @returns {string} */ +function requiredEnv(name) { + const value = process.env[name]; + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`${name} is required and must not be blank; set it before running the installer (see hypercerts-api/README.md)`); + } + return value.trim(); +} + +async function main() { + const baseUrl = new URL(requiredEnv('HAPPYVIEW_BASE_URL')); + const token = requiredEnv('HAPPYVIEW_ADMIN_TOKEN'); + const client = createAdminClient({ baseUrl, token }); + const { assets } = await loadAssets(fileURLToPath(new URL('../manifest.json', import.meta.url))); + const result = await applyAssets(assets, client); + console.log(JSON.stringify(result, null, 2)); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + try { + await main(); + } catch (error) { + // main only throws Error instances; retain the CLI's existing message output. + console.error(/** @type {Error} */ (error).message); + process.exitCode = 1; + } +} diff --git a/hypercerts-api/tooling/lexicon-source.js b/hypercerts-api/tooling/lexicon-source.js new file mode 100644 index 00000000..c7174bf5 --- /dev/null +++ b/hypercerts-api/tooling/lexicon-source.js @@ -0,0 +1,16 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +const require = createRequire(new URL('../package.json', import.meta.url)); +const lexiconPackageRoot = path.dirname(require.resolve('@hypercerts-org/lexicon/package.json')); + +/** @typedef {{ packagePath?: string; path?: string }} LexiconSource */ +/** @param {LexiconSource} source @param {string} [root] @returns {Promise} */ +export async function readLexiconSource(source, root = process.cwd()) { + // path.join rejects non-string segments; these narrow assertions preserve that existing runtime check. + const file = source.packagePath + ? path.join(lexiconPackageRoot, /** @type {string} */ (source.packagePath)) + : path.join(root, /** @type {string} */ (source.path)); + return JSON.parse(await readFile(file, 'utf8')); +} diff --git a/hypercerts-api/tooling/lint-lua.js b/hypercerts-api/tooling/lint-lua.js new file mode 100644 index 00000000..ac1bb9ff --- /dev/null +++ b/hypercerts-api/tooling/lint-lua.js @@ -0,0 +1,71 @@ +import { access, readdir } from 'node:fs/promises'; +import { X_OK } from 'node:constants'; +import { spawnSync } from 'node:child_process'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const defaultRoot = fileURLToPath(new URL('../', import.meta.url)); + +async function luaFiles(directory) { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if (error.code === 'ENOENT') return []; + throw error; + } + + const files = []; + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const file = path.join(directory, entry.name); + if (entry.isDirectory()) files.push(...await luaFiles(file)); + else if (entry.isFile() && entry.name.endsWith('.lua')) files.push(file); + } + return files; +} + +export async function lintLua({ root = defaultRoot, home = homedir(), spawn = spawnSync, log = console.log } = {}) { + const allFiles = await luaFiles(path.join(root, 'lua')); + if (allFiles.length === 0) { + log('No Lua files in this branch; skipping Luacheck.'); + return { skipped: true }; + } + + const endpointFiles = await luaFiles(path.join(root, 'lua', 'endpoints')); + if (endpointFiles.length === 0) { + throw new Error('Lua sources exist, but generated handler bundles are missing; run `pnpm build:lua` first.'); + } + + const args = [ + '--config', + '.luacheckrc', + ...endpointFiles.map((file) => path.relative(root, file)), + ]; + const options = { cwd: root, stdio: 'inherit' }; + let result = spawn('luacheck', args, options); + if (result.error?.code === 'ENOENT') { + const localLuacheck = path.join(home, '.luarocks', 'bin', 'luacheck'); + try { + await access(localLuacheck, X_OK); + result = spawn(localLuacheck, args, options); + } catch (error) { + if (error.code !== 'ENOENT' && error.code !== 'EACCES') throw error; + } + } + if (result.error?.code === 'ENOENT') { + throw new Error('Luacheck is missing. Install it with `luarocks --lua-version=5.4 --local install luacheck 1.2.0`.'); + } + if (result.error) throw result.error; + return { status: result.status ?? 1 }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + try { + const result = await lintLua(); + if (result.status !== undefined) process.exitCode = result.status; + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/hypercerts-api/tooling/lint-lua.test.js b/hypercerts-api/tooling/lint-lua.test.js new file mode 100644 index 00000000..666c802a --- /dev/null +++ b/hypercerts-api/tooling/lint-lua.test.js @@ -0,0 +1,107 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { lintLua } from './lint-lua.js'; + +async function withTempRoot(run) { + const root = await mkdtemp(path.join(os.tmpdir(), 'hypercerts-api-lint-lua-')); + try { + await run(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +test('skips Lua lint when the package has no Lua files', async () => { + await withTempRoot(async (root) => { + const result = await lintLua({ root, log: () => {} }); + assert.deepEqual(result, { skipped: true }); + }); +}); + +test('requires generated endpoint bundles when Lua sources are present', async () => { + await withTempRoot(async (root) => { + await mkdir(path.join(root, 'lua', 'src'), { recursive: true }); + await writeFile(path.join(root, 'lua', 'src', 'getExample.lua'), 'function handle() end\n'); + + await assert.rejects( + lintLua({ root, log: () => {} }), + /generated handler bundles are missing.*pnpm build:lua/, + ); + }); +}); + +test('runs Luacheck against generated bundles with the package config', async () => { + await withTempRoot(async (root) => { + const endpoint = path.join(root, 'lua', 'endpoints', 'getExample.lua'); + await mkdir(path.dirname(endpoint), { recursive: true }); + await writeFile(endpoint, 'function handle() end\n'); + let command; + let args; + let options; + + const result = await lintLua({ + root, + log: () => {}, + spawn: (nextCommand, nextArgs, nextOptions) => { + command = nextCommand; + args = nextArgs; + options = nextOptions; + return { status: 0 }; + }, + }); + + assert.deepEqual(result, { status: 0 }); + assert.equal(command, 'luacheck'); + assert.deepEqual(args, ['--config', '.luacheckrc', 'lua/endpoints/getExample.lua']); + assert.equal(options.cwd, root); + assert.equal(options.stdio, 'inherit'); + }); +}); + +test('uses the default user-local LuaRocks binary when it is not on PATH', async () => { + await withTempRoot(async (root) => { + const home = path.join(root, 'home'); + const endpoint = path.join(root, 'lua', 'endpoints', 'getExample.lua'); + const localLuacheck = path.join(home, '.luarocks', 'bin', 'luacheck'); + await mkdir(path.dirname(endpoint), { recursive: true }); + await mkdir(path.dirname(localLuacheck), { recursive: true }); + await writeFile(endpoint, 'function handle() end\\n'); + await writeFile(localLuacheck, '#!/bin/sh\\n', { mode: 0o755 }); + const commands = []; + + const result = await lintLua({ + root, + home, + log: () => {}, + spawn: (command, args) => { + commands.push({ command, args }); + if (command === 'luacheck') return { error: Object.assign(new Error('not found'), { code: 'ENOENT' }) }; + return { status: 0 }; + }, + }); + + assert.deepEqual(result, { status: 0 }); + assert.deepEqual(commands.map(({ command }) => command), ['luacheck', localLuacheck]); + assert.deepEqual(commands[1].args, ['--config', '.luacheckrc', 'lua/endpoints/getExample.lua']); + }); +}); + +test('explains how to install Luacheck when it is missing', async () => { + await withTempRoot(async (root) => { + const endpoint = path.join(root, 'lua', 'endpoints', 'getExample.lua'); + await mkdir(path.dirname(endpoint), { recursive: true }); + await writeFile(endpoint, 'function handle() end\n'); + + await assert.rejects( + lintLua({ + root, + log: () => {}, + spawn: () => ({ error: Object.assign(new Error('not found'), { code: 'ENOENT' }) }), + }), + /Luacheck is missing.*luarocks --lua-version=5\.4 --local install luacheck 1\.2\.0/, + ); + }); +}); diff --git a/hypercerts-api/tooling/seed.js b/hypercerts-api/tooling/seed.js new file mode 100644 index 00000000..88837a7b --- /dev/null +++ b/hypercerts-api/tooling/seed.js @@ -0,0 +1,94 @@ +import { spawnSync } from 'node:child_process'; +import { accessSync, constants, statSync } from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { seedSql, locationRecords, profileRecords, organizationRecords } from '../tests/fixtures/records.js'; +import { makeDateCaseRows, badDateSeedSql } from '../tests/fixtures/bad-dates.js'; + +export const badDateLocations = await makeDateCaseRows(locationRecords[0], { + did: 'did:plc:baddatefixturesexamplexx', + decorateRecord: (record, rkey) => ({ + ...record, locationType: 'date-test', name: `Date test ${rkey}`, description: 'Synthetic timestamp fixture', + }), +}); + +function disposableTarget(env) { + if (env.HAPPYVIEW_DISPOSABLE_TEST_TARGET !== 'YES') throw new Error('Set HAPPYVIEW_DISPOSABLE_TEST_TARGET=YES only after confirming this is a disposable test database'); + if (env.PGHOSTADDR || env.PGSERVICE || env.PGSERVICEFILE) throw new Error('PGHOSTADDR, PGSERVICE, and PGSERVICEFILE are not allowed; they can override the loopback seed target'); + if (!env.PGDATABASE || !/^\w[\w-]*$/.test(env.PGDATABASE) || !/(^|[_-])test([_-]|$)/i.test(env.PGDATABASE)) { + throw new Error('PGDATABASE must be a simple database name containing a test marker (for example happyview_test), not a URI or conninfo string'); + } + if (!['localhost', '127.0.0.1', '::1'].includes(env.PGHOST)) throw new Error('Set PGHOST explicitly to localhost, 127.0.0.1, or ::1'); + const port = env.PGPORT === undefined ? 5432 : Number(env.PGPORT); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('PGPORT must be an integer from 1 to 65535'); + return { host: env.PGHOST, port, database: env.PGDATABASE, user: env.PGUSER }; +} + +function quote(value) { + if (value === null) return 'NULL'; + const escapedBackslash = String.raw`\\`; + return `E'${String(value).replaceAll('\\', escapedBackslash).replaceAll("'", "''")}'`; +} + +function sqlInput(statements, types = 'text, text, text, text, jsonb, text, text') { + return [ + String.raw`\set ON_ERROR_STOP on`, + `PREPARE happyview_fixture (${types}) AS ${statements[0].sql};`, + ...statements.map(({ params }) => `EXECUTE happyview_fixture(${params.map(quote).join(', ')});`), + 'DEALLOCATE happyview_fixture;', + ].join('\n'); +} + +export function buildSeedInput(env = process.env, rows = [...locationRecords, ...profileRecords, ...organizationRecords]) { + disposableTarget(env); + return sqlInput(seedSql(rows, { disposableTestTarget: true })); +} + +export function buildBadDateSeedInput(env = process.env, rows = badDateLocations) { + disposableTarget(env); + if (!Array.isArray(rows) || rows.length === 0) throw new Error('Seeding requires at least one fixture row; supply a nonempty rows array'); + return sqlInput(badDateSeedSql(rows, { disposableTestTarget: true }), 'text, text, text, text, jsonb, text, text, text'); +} + +function configuredPsqlPath(env) { + const executable = env.PSQL_PATH; + if (!executable || !path.isAbsolute(executable)) { + throw new Error('Set PSQL_PATH to the absolute path of a trusted psql executable; PATH lookup is not allowed'); + } + try { + if (!statSync(executable).isFile()) throw new Error('not a regular file'); + accessSync(executable, constants.X_OK); + } catch (error) { + throw new Error(`PSQL_PATH must point to an existing executable file (${executable}): ${error.message}`, { cause: error }); + } + return executable; +} + +export function psqlTargetArgs(env = process.env) { + const target = disposableTarget(env); + const args = ['--host', target.host, '--port', String(target.port), '--dbname', target.database]; + if (target.user) args.push('--username', target.user); + return args; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const options = process.argv.slice(2); + if (options.length > 1 || (options.length === 1 && options[0] !== '--bad-dates')) { + throw new Error(`Unknown seed option: ${options.join(' ')}; use --bad-dates only to seed invalid-date fixtures`); + } + const input = options.length === 1 ? buildBadDateSeedInput() : buildSeedInput(); + const args = ['--no-psqlrc', '--set', 'ON_ERROR_STOP=1', ...psqlTargetArgs()]; + const executable = configuredPsqlPath(process.env); + const env = { ...process.env }; + delete env.PGHOSTADDR; + delete env.PGSERVICE; + delete env.PGSERVICEFILE; + const result = spawnSync(executable, args, { input, encoding: 'utf8', env, stdio: ['pipe', 'inherit', 'inherit'], shell: false }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`psql failed with exit status ${result.status}`); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/hypercerts-api/tooling/seed.test.js b/hypercerts-api/tooling/seed.test.js new file mode 100644 index 00000000..2b988674 --- /dev/null +++ b/hypercerts-api/tooling/seed.test.js @@ -0,0 +1,260 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { locationRecords, profileRecords, organizationRecords } from '../tests/fixtures/records.js'; +import { badDateLocations, buildBadDateSeedInput, buildSeedInput, psqlTargetArgs } from './seed.js'; + +const packageRoot = fileURLToPath(new URL('..', import.meta.url)); + +async function withCopiedSeedModule(run) { + const root = await mkdtemp(path.join(packageRoot, '.seed-cli-#')); + try { + await mkdir(path.join(root, 'tooling'), { recursive: true }); + await mkdir(path.join(root, 'tests/fixtures'), { recursive: true }); + await copyFile(new URL('./seed.js', import.meta.url), path.join(root, 'tooling/seed.js')); + await copyFile(new URL('../tests/fixtures/records.js', import.meta.url), path.join(root, 'tests/fixtures/records.js')); + await copyFile(new URL('../tests/fixtures/bad-dates.js', import.meta.url), path.join(root, 'tests/fixtures/bad-dates.js')); + await writeFile(path.join(root, 'package.json'), '{"type":"module"}\n'); + return await run(path.join(root, 'tooling/seed.js')); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +test('seed command refuses absent opt-in and any non-loopback/non-test database target', () => { + assert.throws(() => buildSeedInput({}), /HAPPYVIEW_DISPOSABLE_TEST_TARGET=YES/); + assert.throws(() => buildSeedInput({ HAPPYVIEW_DISPOSABLE_TEST_TARGET: 'YES', PGDATABASE: 'production', PGHOST: 'localhost' }), /test marker/); + assert.throws(() => buildSeedInput({ HAPPYVIEW_DISPOSABLE_TEST_TARGET: 'YES', PGDATABASE: 'happyview_test', PGHOST: 'db.example.com' }), /PGHOST explicitly/); + assert.throws(() => buildSeedInput({ HAPPYVIEW_DISPOSABLE_TEST_TARGET: 'YES', PGDATABASE: 'postgres://127.0.0.1/happyview_test', PGHOST: '127.0.0.1' }), /simple database name/); + assert.throws(() => buildSeedInput({ HAPPYVIEW_DISPOSABLE_TEST_TARGET: 'YES', PGDATABASE: 'host=127.0.0.1 dbname=happyview_test', PGHOST: '127.0.0.1' }), /simple database name/); + assert.throws(() => buildSeedInput({ HAPPYVIEW_DISPOSABLE_TEST_TARGET: 'YES', PGDATABASE: 'happyview_test', PGHOST: '127.0.0.1', PGHOSTADDR: '203.0.113.1' }), /PGHOSTADDR/); + assert.throws(() => buildSeedInput({ HAPPYVIEW_DISPOSABLE_TEST_TARGET: 'YES', PGDATABASE: 'happyview_test', PGHOST: '127.0.0.1', PGSERVICE: 'production' }), /PGSERVICE/); + assert.throws(() => buildSeedInput({ HAPPYVIEW_DISPOSABLE_TEST_TARGET: 'YES', PGDATABASE: 'happyview_test', PGHOST: '127.0.0.1', PGSERVICEFILE: '/tmp/pg_service.conf' }), /PGSERVICEFILE/); +}); + +test('seed input selects only explicitly supplied non-location rows', () => { + const custom = { + uri: 'at://did:web:custom.example/org.hypercerts.custom/item', + did: 'did:web:custom.example', collection: 'org.hypercerts.custom', rkey: 'item', + record: { $type: 'org.hypercerts.custom', name: "O'Brien \\woods" }, + cid: 'bafycustom', indexedAt: '2025-01-02T03:04:05.000Z', + }; + const sql = buildSeedInput(safeSeedEnv, [custom]); + assert.match(sql, /PREPARE happyview_fixture \(text, text, text, text, jsonb, text, text\)/); + assert.equal((sql.match(/^EXECUTE happyview_fixture\(/gm) ?? []).length, 1); + assert.match(sql, /org\.hypercerts\.custom/); + assert.match(sql, /O''Brien/); + assert.match(sql, /\\\\woods/); + assert.doesNotMatch(sql, /app\.certified\.location|app\.certified\.actor/); + assert.throws(() => buildSeedInput({ ...safeSeedEnv, PGHOST: 'db.example.com' }, [custom]), /PGHOST explicitly/); +}); + +test('seed input refuses empty row sets instead of generating invalid PREPARE SQL', () => { + assert.throws(() => buildSeedInput(safeSeedEnv, []), /at least one.*row/i); + assert.throws(() => buildBadDateSeedInput(safeSeedEnv, []), /at least one.*row/i); +}); + +test('seed SQL uses prepared parameters and only upserts fixture records', () => { + const env = { HAPPYVIEW_DISPOSABLE_TEST_TARGET: 'YES', PGDATABASE: 'happyview_test', PGHOST: '127.0.0.1', PGPORT: '5433', PGUSER: 'test_user' }; + const sql = buildSeedInput(env); + assert.deepEqual(psqlTargetArgs(env), ['--host', '127.0.0.1', '--port', '5433', '--dbname', 'happyview_test', '--username', 'test_user']); + assert.match(sql, /PREPARE happyview_fixture/); + assert.match(sql, /ON CONFLICT \(uri\) DO UPDATE/); + assert.doesNotMatch(sql, /DELETE|TRUNCATE|DROP/i); + assert.match(sql, /EXECUTE happyview_fixture/); +}); + +test('seed SQL E-escapes fixture backslashes and apostrophes', () => { + const fixture = locationRecords.find(({ rkey }) => rkey === '3jzfcijpj2z2d'); + const originalName = fixture.record.name; + try { + fixture.record.name = "O'Brien \\\\woods"; + const sql = buildSeedInput({ + HAPPYVIEW_DISPOSABLE_TEST_TARGET: 'YES', + PGDATABASE: 'happyview_test', + PGHOST: '127.0.0.1', + }); + assert.ok(sql.includes("E'")); + assert.ok(sql.includes(`"name":"O''Brien ${'\\\\'.repeat(4)}woods"`)); + } finally { + fixture.record.name = originalName; + } +}); + +const safeSeedEnv = { + HAPPYVIEW_DISPOSABLE_TEST_TARGET: 'YES', + PGHOST: '127.0.0.1', + PGDATABASE: 'happyview_test', +}; + +async function withFakeExecutables(run) { + const root = await mkdtemp(path.join(packageRoot, '.seed-executables-')); + const selected = path.join(root, 'selected'); + const competing = path.join(root, 'psql'); + try { + await writeFile(selected, '#!/bin/sh\nprintf "selected executable\\n" >&2\n', { mode: 0o700 }); + await writeFile(competing, '#!/bin/sh\nprintf "PATH executable\\n" >&2\n', { mode: 0o700 }); + return await run({ selected, competing, root }); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +test('seed CLI rejects missing PSQL_PATH without running the PATH executable', async () => { + await withCopiedSeedModule((script) => withFakeExecutables(({ root }) => { + const child = spawnSync(process.execPath, [script], { + encoding: 'utf8', + env: { ...safeSeedEnv, PATH: root }, + }); + assert.equal(child.status, 1, child.stderr); + assert.match(child.stderr, /PSQL_PATH.*absolute/); + assert.doesNotMatch(child.stderr, /PATH executable/); + })); +}); + +test('seed CLI rejects relative PSQL_PATH without running the PATH executable', async () => { + await withCopiedSeedModule((script) => withFakeExecutables(({ root }) => { + const child = spawnSync(process.execPath, [script], { + encoding: 'utf8', + env: { ...safeSeedEnv, PSQL_PATH: 'psql', PATH: root }, + }); + assert.equal(child.status, 1, child.stderr); + assert.match(child.stderr, /PSQL_PATH.*absolute/); + assert.doesNotMatch(child.stderr, /PATH executable/); + })); +}); + +test('seed CLI runs only the configured executable even when PATH contains psql', async () => { + await withCopiedSeedModule((script) => withFakeExecutables(({ selected, root }) => { + const child = spawnSync(process.execPath, [script], { + encoding: 'utf8', + env: { ...safeSeedEnv, PSQL_PATH: selected, PATH: root }, + }); + assert.equal(child.status, 0, child.stderr); + assert.match(child.stderr, /selected executable/); + assert.doesNotMatch(child.stderr, /PATH executable/); + })); +}); + +test('seed CLI rejects a nonexistent absolute executable without PATH fallback', async () => { + await withCopiedSeedModule((script) => withFakeExecutables(({ root }) => { + const child = spawnSync(process.execPath, [script], { + encoding: 'utf8', + env: { ...safeSeedEnv, PSQL_PATH: path.join(root, 'missing'), PATH: root }, + }); + assert.equal(child.status, 1, child.stderr); + assert.match(child.stderr, /PSQL_PATH.*existing executable file/); + assert.doesNotMatch(child.stderr, /PATH executable/); + })); +}); + +test('seed CLI rejects an absolute path that is not executable', async () => { + await withCopiedSeedModule((script) => withFakeExecutables(async ({ selected, root }) => { + await chmod(selected, 0o600); + const child = spawnSync(process.execPath, [script], { + encoding: 'utf8', + env: { ...safeSeedEnv, PSQL_PATH: selected, PATH: root }, + }); + assert.equal(child.status, 1, child.stderr); + assert.match(child.stderr, /PSQL_PATH.*executable/); + assert.doesNotMatch(child.stderr, /PATH executable/); + })); +}); + +test('bad-date location rows stay isolated from routine fixtures', () => { + const normal = [...locationRecords, ...profileRecords, ...organizationRecords]; + const normalDids = new Set(normal.map(({ did }) => did)); + const normalUris = new Set(normal.map(({ uri }) => uri)); + assert.equal(new Set(badDateLocations.map(({ uri }) => uri)).size, 14); + for (const row of badDateLocations) { + assert.equal(row.did, 'did:plc:baddatefixturesexamplexx'); + assert.equal(row.uri, `at://${row.did}/${row.collection}/${row.rkey}`); + assert.equal(normalDids.has(row.did), false); + assert.equal(normalUris.has(row.uri), false); + assert.equal(row.record.locationType, 'date-test'); + } +}); + +test('bad-date seed input uses eight parameters only for the fourteen isolated fixtures', async () => { + const sql = buildBadDateSeedInput(safeSeedEnv); + assert.match(sql, /PREPARE happyview_fixture \(text, text, text, text, jsonb, text, text, text\)/); + assert.match(sql, /\$7, \$8\) ON CONFLICT/); + assert.equal((sql.match(/^EXECUTE happyview_fixture\(/gm) ?? []).length, 14); + assert.match(sql, /did:plc:baddatefixturesexamplexx/); + assert.doesNotMatch(sql, /3jzfcijpj2z2a/); + assert.match(sql, /, NULL, E'2025-01-02T03:04:05\.123456Z'\)/); // absent indexed_at is SQL NULL, not text 'null' + const fixture = badDateLocations[0]; + const originalName = fixture.record.name; + try { + fixture.record.name = "O'Brien \\woods"; + assert.ok(buildBadDateSeedInput(safeSeedEnv).includes(`"name":"O''Brien ${'\\'.repeat(4)}woods"`)); + } finally { + fixture.record.name = originalName; + } + const normal = buildSeedInput(safeSeedEnv); + assert.match(normal, /PREPARE happyview_fixture \(text, text, text, text, jsonb, text, text\)/); + assert.doesNotMatch(normal, /did:plc:baddatefixturesexamplexx/); +}); + +test('bad-date CLI is opt-in, passes eight-parameter SQL to selected executable, and rejects unknown flags', async () => { + await withCopiedSeedModule((script) => withFakeExecutables(async ({ selected, root }) => { + const capture = path.join(root, 'input.sql'); + await writeFile(selected, '#!/bin/sh\n/bin/cat > "$SEED_CAPTURE"\n', { mode: 0o700 }); + const env = { ...safeSeedEnv, PSQL_PATH: selected, PATH: root, SEED_CAPTURE: capture }; + const normal = spawnSync(process.execPath, [script], { encoding: 'utf8', env }); + assert.equal(normal.status, 0, normal.stderr); + const normalSql = await readFile(capture, 'utf8'); + const executions = normalSql.split('\n').filter((line) => line.startsWith('EXECUTE happyview_fixture(')); + const expectedRows = [...locationRecords, ...profileRecords, ...organizationRecords]; + assert.equal(executions.length, expectedRows.length); + for (const row of expectedRows) { + const matching = executions.filter((line) => line.startsWith(`EXECUTE happyview_fixture(E'${row.uri}', `)); + assert.equal(matching.length, 1, `expected exactly one default CLI row for ${row.uri}`); + const recordJson = JSON.stringify(row.record).replaceAll('\\', '\\\\').replaceAll("'", "''"); + assert.equal(matching[0], `EXECUTE happyview_fixture(E'${row.uri}', E'${row.did}', E'${row.collection}', E'${row.rkey}', E'${recordJson}', E'${row.cid}', E'${row.indexedAt}');`); + } + assert.doesNotMatch(normalSql, /did:plc:baddatefixturesexamplexx/); + const bad = spawnSync(process.execPath, [script, '--bad-dates'], { encoding: 'utf8', env }); + assert.equal(bad.status, 0, bad.stderr); + assert.match(await readFile(capture, 'utf8'), /did:plc:baddatefixturesexamplexx/); + const badSql = await readFile(capture, 'utf8'); + for (const args of [['--unknown'], ['--bad-dates', '--unexpected']]) { + const child = spawnSync(process.execPath, [script, ...args], { encoding: 'utf8', env }); + assert.equal(child.status, 1, child.stderr); + assert.match(child.stderr, /Unknown seed option/); + assert.equal(await readFile(capture, 'utf8'), badSql); + } + })); +}); + +test('bad-date CLI retains disposable target and absolute PSQL_PATH checks', async () => { + await withCopiedSeedModule((script) => withFakeExecutables(({ selected, root }) => { + for (const [env, message] of [ + [{ ...safeSeedEnv, HAPPYVIEW_DISPOSABLE_TEST_TARGET: undefined, PSQL_PATH: selected }, /HAPPYVIEW_DISPOSABLE_TEST_TARGET=YES/], + [{ ...safeSeedEnv, PGHOST: 'db.example.com', PSQL_PATH: selected }, /PGHOST explicitly/], + [{ ...safeSeedEnv, PGHOSTADDR: '203.0.113.1', PSQL_PATH: selected }, /PGHOSTADDR/], + [{ ...safeSeedEnv, PGDATABASE: 'production', PSQL_PATH: selected }, /test marker/], + [{ ...safeSeedEnv, PSQL_PATH: 'psql', PATH: root }, /PSQL_PATH.*absolute/], + ]) { + const child = spawnSync(process.execPath, [script, '--bad-dates'], { encoding: 'utf8', env }); + assert.equal(child.status, 1, child.stderr); + assert.match(child.stderr, message); + assert.doesNotMatch(child.stderr, /selected executable|PATH executable/); + } + })); +}); + +test('seed CLI runs from a copied module path containing # and fails closed before psql', async () => { + await withCopiedSeedModule((script) => { + const child = spawnSync(process.execPath, [script], { + encoding: 'utf8', + env: { PATH: process.env.PATH ?? '' }, + }); + assert.equal(child.status, 1, child.stderr); + assert.match(child.stderr, /HAPPYVIEW_DISPOSABLE_TEST_TARGET=YES/); + assert.doesNotMatch(child.stderr, /psql failed/); + }); +}); diff --git a/hypercerts-api/tsconfig.json b/hypercerts-api/tsconfig.json new file mode 100644 index 00000000..f96aaf3a --- /dev/null +++ b/hypercerts-api/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"] + }, + "include": ["tooling/installer.js"] +}