From f9682b751d29c70e99ab3663c1539b3dc3301300 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Thu, 24 Sep 2026 21:34:17 +0600 Subject: [PATCH 01/11] hypercerts-api: add modular installer and offline test kit --- hypercerts-api/README.md | 17 ++ hypercerts-api/package.json | 17 ++ hypercerts-api/pnpm-lock.yaml | 231 +++++++++++++++ hypercerts-api/tests/contracts/helpers.js | 18 ++ .../tests/contracts/helpers.test.js | 14 + hypercerts-api/tests/fixtures/bad-dates.js | 42 +++ .../tests/fixtures/bad-dates.test.js | 34 +++ .../tests/fixtures/fixtures.test.js | 56 ++++ hypercerts-api/tests/fixtures/records.js | 88 ++++++ .../tooling/installer-bundle.test.js | 164 +++++++++++ hypercerts-api/tooling/installer-core.test.js | 66 +++++ hypercerts-api/tooling/installer.js | 211 ++++++++++++++ hypercerts-api/tooling/lexicon-source.js | 13 + hypercerts-api/tooling/seed.js | 94 ++++++ hypercerts-api/tooling/seed.test.js | 271 ++++++++++++++++++ 15 files changed, 1336 insertions(+) create mode 100644 hypercerts-api/README.md create mode 100644 hypercerts-api/package.json create mode 100644 hypercerts-api/pnpm-lock.yaml create mode 100644 hypercerts-api/tests/contracts/helpers.js create mode 100644 hypercerts-api/tests/contracts/helpers.test.js create mode 100644 hypercerts-api/tests/fixtures/bad-dates.js create mode 100644 hypercerts-api/tests/fixtures/bad-dates.test.js create mode 100644 hypercerts-api/tests/fixtures/fixtures.test.js create mode 100644 hypercerts-api/tests/fixtures/records.js create mode 100644 hypercerts-api/tooling/installer-bundle.test.js create mode 100644 hypercerts-api/tooling/installer-core.test.js create mode 100644 hypercerts-api/tooling/installer.js create mode 100644 hypercerts-api/tooling/lexicon-source.js create mode 100644 hypercerts-api/tooling/seed.js create mode 100644 hypercerts-api/tooling/seed.test.js diff --git a/hypercerts-api/README.md b/hypercerts-api/README.md new file mode 100644 index 00000000..6b9a7f3b --- /dev/null +++ b/hypercerts-api/README.md @@ -0,0 +1,17 @@ +# Hypercerts API installer foundation + +This package provides a reusable, conflict-safe HappyView admin installer, pinned Lexicon dependencies, and guarded test seeding tools. The included location records are example fixtures, not a restriction on the tooling. It does not yet include an installable manifest or Lua handlers; those are added in the dependent location API PR. Do not run `tooling/installer.js` against a target from this branch. + +Fixture CIDs are computed from stored record JSON with `@atcute/cbor` and `@atcute/cid`. If you previously seeded these fixtures, rerun the guarded seed command on the disposable test database before comparing CIDs: the blob-backed fixture's CID changed because the previous `jsonToLex` conversion added a field absent from stored JSON. + +From this directory, install dependencies with `pnpm install --frozen-lockfile`, then run offline checks with `pnpm test:unit`. The default installer entry point remains `node tooling/installer.js`, but **this branch has no production `manifest.json` or Lua handlers; do not run it against a target**. The dependent API branch must supply a complete production bundle and its domain-specific completeness tests before installation on an explicitly approved HappyView target. + +The production `manifest.json` should list module manifests once; this example uses a location module: + +```json +{ "modules": ["modules/shared/manifest.json", "modules/location/manifest.json"] } +``` + +Each referenced module manifest has `{ "assets": [...] }` using the existing asset declarations (`id`, `kind`, `config`, optional `dependsOn`). Lexicons use `packagePath` (relative to `@hypercerts-org/lexicon`) or `path`; scripts use `path`. Local paths resolve relative to **the declaring module manifest**, not the bundle. Shared schemas belong to one module; other modules refer to their IDs in `dependsOn`. The installer combines all modules into one run, rejects duplicate IDs, missing dependencies and cycles, validates every script source, then reads all installed assets before writing any. Unchanged assets are skipped; conflicts require manual resolution. Domain-specific handler completeness belongs in domain tests, not the generic installer. + +Seeding bypasses ingestion and is only for a separately approved, disposable loopback PostgreSQL test database; never use these fixtures on persistent data. `seedSql(rows, { disposableTestTarget: true })` from `tests/fixtures/records.js` produces SQL statements for supplied record rows. `buildSeedInput(env = process.env, rows = existing default example rows)` and `buildBadDateSeedInput(env = process.env, rows = badDateLocations)` from `tooling/seed.js` accept supplied rows while keeping the location examples as CLI defaults. Malformed `createdAt` test rows can be built for any record collection with `makeDateCaseRows(baseRecord, { did })` from `tests/fixtures/bad-dates.js`. Convert those rows to SQL with `badDateSeedSql(rows, { disposableTestTarget: true })` only for an approved disposable test target. The guarded `seed:bad-dates` command still seeds only location rows by default. See the dependent PR for the full install and contract-test procedure. diff --git a/hypercerts-api/package.json b/hypercerts-api/package.json new file mode 100644 index 00000000..5d971fa7 --- /dev/null +++ b/hypercerts-api/package.json @@ -0,0 +1,17 @@ +{ + "name": "@happyview/hypercerts-api-test-kit", + "private": true, + "type": "module", + "dependencies": { + "@atcute/cbor": "2.3.2", + "@atcute/cid": "2.4.1", + "@atproto/lexicon": "0.7.4", + "@atproto/syntax": "0.6.4", + "@hypercerts-org/lexicon": "1.4.0" + }, + "scripts": { + "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" + } +} diff --git a/hypercerts-api/pnpm-lock.yaml b/hypercerts-api/pnpm-lock.yaml new file mode 100644 index 00000000..79a4aaaa --- /dev/null +++ b/hypercerts-api/pnpm-lock.yaml @@ -0,0 +1,231 @@ +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.7.4 + version: 0.7.4 + '@atproto/syntax': + specifier: 0.6.4 + version: 0.6.4 + '@hypercerts-org/lexicon': + specifier: 1.4.0 + version: 1.4.0(@atproto/xrpc@0.8.13) + +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/lexicon@0.7.4': + resolution: {integrity: sha512-ulk4RGwMBp4vbkTOZcIwZJeTEPlj3PImOnx9TqxSxMDnBdySxarpd0Aprg7+iAvGyKTsMcrYHpeoLukZaquk0A==} + engines: {node: '>=22'} + + '@atproto/syntax@0.5.4': + resolution: {integrity: sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw==} + + '@atproto/syntax@0.6.4': + resolution: {integrity: sha512-ELgpShRGMF65cvLXcoMCZFL0HBzy67yz/Nlhox3yOtTh120B09KjjvZYXhMysVog3nUJqv2EW5rf1VRYCeM/hw==} + engines: {node: '>=22'} + + '@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'} + + '@hypercerts-org/lexicon@1.4.0': + resolution: {integrity: sha512-M1yG6+EI1yOpCZU6CO2plZcMS9fcXq8i/h/gXM/sj9txLH4Roo6de5Rs7L7Zhn/8iYoIp+akuGoolZ0e3EuUcg==} + peerDependencies: + '@atproto/xrpc': '*' + + iso-datestring-validator@2.2.2: + resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==} + + multiformats@13.4.2: + resolution: {integrity: sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==} + + multiformats@9.9.0: + resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + uint8arrays@3.0.0: + resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==} + + unicode-segmenter@0.14.5: + resolution: {integrity: sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==} + + 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/lexicon@0.7.4': + dependencies: + '@atproto/common-web': 0.5.12 + '@atproto/syntax': 0.6.4 + multiformats: 13.4.2 + zod: 3.25.76 + + '@atproto/syntax@0.5.4': + dependencies: + tslib: 2.8.1 + + '@atproto/syntax@0.6.4': + dependencies: + iso-datestring-validator: 2.2.2 + 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 + + '@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 + + iso-datestring-validator@2.2.2: {} + + multiformats@13.4.2: {} + + multiformats@9.9.0: {} + + tslib@2.8.1: {} + + uint8arrays@3.0.0: + dependencies: + multiformats: 9.9.0 + + unicode-segmenter@0.14.5: {} + + 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..91989b4e --- /dev/null +++ b/hypercerts-api/tests/contracts/helpers.test.js @@ -0,0 +1,14 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { contractUrl, requireContractTarget } from './helpers.js'; + +test('repeated arrays are encoded as unbracketed query keys and empty optional arrays are omitted', () => { + assert.equal(contractUrl('http://127.0.0.1:8080', 'app.certified.location.listLocations', { authors: ['did:plc:a', 'did:plc:b'], uris: [], search: 'river bank' }), + 'http://127.0.0.1:8080/xrpc/app.certified.location.listLocations?authors=did%3Aplc%3Aa&authors=did%3Aplc%3Ab&search=river%20bank'); +}); + +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..bfd76e2e --- /dev/null +++ b/hypercerts-api/tests/fixtures/fixtures.test.js @@ -0,0 +1,56 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as CID from '@atcute/cid'; +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); + 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..72126c9b --- /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', + description: 'Community forest restoration area', 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: String.raw`Literal 100%_\path and + spaces` }, + name: String.raw`Literal 100%_\path and + spaces`, description: 'Search metacharacter 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..ddc70a76 --- /dev/null +++ b/hypercerts-api/tooling/installer-bundle.test.js @@ -0,0 +1,164 @@ +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('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('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' }], files: { 'schema.json': '{}' }, + } }); + 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', dependsOn }] })); + await assert.rejects(() => loadAssets(manifest), /Module shared\/manifest.json.*assets\[0\].*dependsOn.*array.*nonempty.*fix/i); + } +}); + +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('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' }], files: { 'schema.json': '{}' } }, + beta: { assets: [{ id: 'shared.schema', kind: 'lexicon', path: 'schema.json' }], files: { 'schema.json': '{}' } }, + }); + 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', dependsOn: ['alpha.schema'] }]], + ]) { + const manifest = await bundle(t, { + alpha: { assets: [{ id: 'alpha.schema', kind: 'lexicon', path: 'schema.json', dependsOn: [dependency] }], files: { 'schema.json': '{}' } }, + beta: { assets: other, files: { 'schema.json': '{}' } }, + }); + 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': '{}' } }, + later: { assets: [{ id: 'later.schema', kind: 'lexicon', path: 'schema.json', config: { backfill: false } }], files: { 'schema.json': '{}' } }, + }); + 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-core.test.js b/hypercerts-api/tooling/installer-core.test.js new file mode 100644 index 00000000..c17f550d --- /dev/null +++ b/hypercerts-api/tooling/installer-core.test.js @@ -0,0 +1,66 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAssets, createAdminClient, orderAssets } 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('admin client never forwards cookies across redirects and rejects URL credentials', async () => { + const calls = []; + const admin = createAdminClient({ baseUrl: 'http://localhost:8080', cookie: 'secret', fetchImpl: async (url, options) => { + calls.push({ url: String(url), options }); + throw new Error('redirect disallowed'); + } }); + await assert.rejects(() => admin.read({ id: 'x', kind: 'script' }), /request failed before receiving a response/); + assert.equal(calls[0].options.redirect, 'error'); + assert.equal(calls[0].options.headers.cookie, 'secret'); + assert.throws(() => createAdminClient({ baseUrl: 'https://user:password@example.com', cookie: 'secret' }), /no URL credentials/); +}); + +test('admin client rejects insecure remote targets before sending a request', () => { + let called = false; + assert.throws(() => createAdminClient({ + baseUrl: 'http://example.com', cookie: 'private', + fetchImpl: async () => { called = true; }, + }), /HTTPS/); + assert.equal(called, false); +}); diff --git a/hypercerts-api/tooling/installer.js b/hypercerts-api/tooling/installer.js new file mode 100644 index 00000000..a65c1f32 --- /dev/null +++ b/hypercerts-api/tooling/installer.js @@ -0,0 +1,211 @@ +import { readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { readLexiconSource } from './lexicon-source.js'; + +export function semanticJson(value) { + if (Array.isArray(value)) return value.map(semanticJson); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.keys(value).sort((a, b) => { + if (a < b) return -1; + if (a > b) return 1; + return 0; + }).map((key) => [key, semanticJson(value[key])])); + } + return value; +} + +export function compareAsset(asset, installed) { + if (installed == null) return 'missing'; + if (JSON.stringify(semanticJson(installed.config ?? {})) !== JSON.stringify(semanticJson(asset.config ?? {}))) return 'conflict'; + if (asset.kind === 'lexicon' && JSON.stringify(semanticJson(installed.lexicon_json)) !== JSON.stringify(semanticJson(asset.lexicon_json))) return 'conflict'; + if (asset.kind === 'script' && installed.body !== asset.body) return 'conflict'; + return 'unchanged'; +} + +export function createAdminClient({ baseUrl, cookie, fetchImpl = globalThis.fetch }) { + let target; + try { + target = new URL(baseUrl); + } catch { + throw new Error('HappyView admin URL must be a valid HTTP(S) URL'); + } + const hostname = target.hostname.toLowerCase().replace(/^\[|\]$/g, ''); + 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'); + } + async function request(method, route, body) { + let response; + try { + response = await fetchImpl(new URL(route, target), { + method, + headers: { cookie, ...(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(); + } + return { + 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; + return asset.kind === 'lexicon' + ? { config: { backfill: row.backfill, target_collection: row.target_collection ?? undefined, action: row.action ?? undefined, token_cost: row.token_cost ?? undefined }, lexicon_json: row.lexicon_json } + : { config: { script_type: row.script_type, description: row.description ?? undefined }, body: row.body }; + }, + 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 }); + } else { + await request('POST', '/admin/scripts', { id: asset.id, script_type: asset.config.script_type, description: asset.config.description, body: asset.body }); + } + }, + }; +} + +export function orderAssets(assets) { + 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); + } + const ordered = []; + const visiting = new Set(); + const visited = new Set(); + 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; +} + +export async function applyAssets(assets, client) { + const ordered = orderAssets(assets); + 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 }); + } + 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 = 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) }; +} + +/** 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. + */ +export async function loadAssets(manifestPath) { + let manifest; + try { + manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + } catch (cause) { + throw new Error(`Bundle ${manifestPath} is missing or invalid (${cause.message}); create or fix the root manifest before installing`, { cause }); + } + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest) || !Array.isArray(manifest.modules) || manifest.modules.length === 0) { + throw new Error(`Bundle ${manifestPath} must list at least one module manifest in modules; add a module before installing`); + } + const assets = []; + const owners = new Map(); + for (const [moduleIndex, modulePath] of manifest.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`); + } + const file = path.resolve(path.dirname(manifestPath), modulePath); + let module; + try { + module = JSON.parse(await readFile(file, 'utf8')); + } catch (cause) { + throw new Error(`Module ${modulePath} is missing or invalid (${cause.message}); check the bundle's modules list and module manifest`, { cause }); + } + if (!module || typeof module !== 'object' || Array.isArray(module) || !Array.isArray(module.assets)) { + throw new Error(`Module ${modulePath} must declare an assets array; fix its manifest before installing`); + } + for (const [assetIndex, entry] of module.assets.entries()) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry) || typeof entry.id !== 'string' || !entry.id.trim() || !['lexicon', 'script'].includes(entry.kind)) { + throw new Error(`Module ${modulePath} assets[${assetIndex}] needs a nonempty string id and kind lexicon or script; fix this asset before installing`); + } + if (entry.dependsOn !== undefined && (!Array.isArray(entry.dependsOn) || entry.dependsOn.some((id) => typeof id !== 'string' || !id.trim()))) { + throw new Error(`Module ${modulePath} assets[${assetIndex}] (${entry.id}) dependsOn must be an array of nonempty string asset IDs; fix its dependencies before installing`); + } + if (owners.has(entry.id)) { + throw new Error(`Duplicate asset ${entry.id} in modules ${owners.get(entry.id)} and ${modulePath}; declare it in one owner only`); + } + owners.set(entry.id, modulePath); + const root = path.dirname(file); + const asset = { ...entry }; + try { + if (entry.kind === 'lexicon') { + asset.lexicon_json = await readLexiconSource(entry, root); + } else { + if (!entry.path) throw new Error('script source path is missing'); + const source = path.resolve(root, entry.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'); + } + } catch (cause) { + throw new Error(`Asset ${entry.id} in module ${modulePath}: source ${entry.path ?? entry.packagePath ?? '(unset)'} is missing, invalid or empty (${cause.message}); fix the declaration or source before installing`, { cause }); + } + assets.push(asset); + } + } + orderAssets(assets); + return { assets }; +} + +function requiredEnv(name) { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required; see hypercerts-api/README.md`); + return value; +} + +async function main() { + const { assets } = await loadAssets(fileURLToPath(new URL('../manifest.json', import.meta.url))); + const baseUrl = new URL(requiredEnv('HAPPYVIEW_BASE_URL')); + const cookie = requiredEnv('HAPPYVIEW_SESSION_COOKIE'); + const client = createAdminClient({ baseUrl, cookie }); + 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) { + console.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..aa92ef38 --- /dev/null +++ b/hypercerts-api/tooling/lexicon-source.js @@ -0,0 +1,13 @@ +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')); + +export async function readLexiconSource(source, root = process.cwd()) { + const file = source.packagePath + ? path.join(lexiconPackageRoot, source.packagePath) + : path.join(root, source.path); + return JSON.parse(await readFile(file, 'utf8')); +} diff --git a/hypercerts-api/tooling/seed.js b/hypercerts-api/tooling/seed.js new file mode 100644 index 00000000..9f74e5d8 --- /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}`); + } + 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..a2f4b6aa --- /dev/null +++ b/hypercerts-api/tooling/seed.test.js @@ -0,0 +1,271 @@ +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('bad-date seed input accepts non-location rows with eight parameters', () => { + const custom = { ...badDateLocations[0], collection: 'org.hypercerts.custom', uri: 'at://did:web:custom.example/org.hypercerts.custom/badmissing', record: { $type: 'org.hypercerts.custom', createdAt: null } }; + const sql = buildBadDateSeedInput(safeSeedEnv, [custom]); + assert.match(sql, /PREPARE happyview_fixture \(text, text, text, text, jsonb, text, text, text\)/); + assert.equal((sql.match(/^EXECUTE happyview_fixture\(/gm) ?? []).length, 1); + assert.match(sql, /org\.hypercerts\.custom/); + assert.doesNotMatch(sql, /app\.certified\.location/); +}); + +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 and filters', () => { + 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'); + assert.equal(`${row.record.name} ${row.record.description}`.toLowerCase().includes('community forest'), false); + assert.equal(`${row.record.name} ${row.record.description}`.includes(String.raw`100%_\path`), false); + } +}); + +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/); + }); +}); From b4e21a80b8c7ad4d0d82040ff938568d1791d479 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Thu, 24 Sep 2026 21:34:24 +0600 Subject: [PATCH 02/11] hypercerts-api: validate installer bundles and document setup --- hypercerts-api/README.md | 73 +++++++- hypercerts-api/package.json | 2 - hypercerts-api/pnpm-lock.yaml | 26 --- .../tooling/installer-bundle.test.js | 45 ++++- hypercerts-api/tooling/installer-core.test.js | 26 +++ hypercerts-api/tooling/installer.js | 172 ++++++++++++------ 6 files changed, 246 insertions(+), 98 deletions(-) diff --git a/hypercerts-api/README.md b/hypercerts-api/README.md index 6b9a7f3b..2501c588 100644 --- a/hypercerts-api/README.md +++ b/hypercerts-api/README.md @@ -1,17 +1,76 @@ -# Hypercerts API installer foundation +# Hypercerts API for HappyView -This package provides a reusable, conflict-safe HappyView admin installer, pinned Lexicon dependencies, and guarded test seeding tools. The included location records are example fixtures, not a restriction on the tooling. It does not yet include an installable manifest or Lua handlers; those are added in the dependent location API PR. Do not run `tooling/installer.js` against a target from this branch. +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. -Fixture CIDs are computed from stored record JSON with `@atcute/cbor` and `@atcute/cid`. If you previously seeded these fixtures, rerun the guarded seed command on the disposable test database before comparing CIDs: the blob-backed fixture's CID changed because the previous `jsonToLex` conversion added a field absent from stored JSON. +**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. -From this directory, install dependencies with `pnpm install --frozen-lockfile`, then run offline checks with `pnpm test:unit`. The default installer entry point remains `node tooling/installer.js`, but **this branch has no production `manifest.json` or Lua handlers; do not run it against a target**. The dependent API branch must supply a complete production bundle and its domain-specific completeness tests before installation on an explicitly approved HappyView target. +## Get started -The production `manifest.json` should list module manifests once; this example uses a location module: +From `hypercerts-api/`: + +```sh +pnpm install --frozen-lockfile +pnpm test:unit +``` + +These checks run offline; they 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. + +## 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 referenced module manifest has `{ "assets": [...] }` using the existing asset declarations (`id`, `kind`, `config`, optional `dependsOn`). Lexicons use `packagePath` (relative to `@hypercerts-org/lexicon`) or `path`; scripts use `path`. Local paths resolve relative to **the declaring module manifest**, not the bundle. Shared schemas belong to one module; other modules refer to their IDs in `dependsOn`. The installer combines all modules into one run, rejects duplicate IDs, missing dependencies and cycles, validates every script source, then reads all installed assets before writing any. Unchanged assets are skipped; conflicts require manual resolution. Domain-specific handler completeness belongs in domain tests, not the generic installer. +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`; it reads `HAPPYVIEW_BASE_URL` and `HAPPYVIEW_SESSION_COOKIE` from the environment. Do not use it 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`. -Seeding bypasses ingestion and is only for a separately approved, disposable loopback PostgreSQL test database; never use these fixtures on persistent data. `seedSql(rows, { disposableTestTarget: true })` from `tests/fixtures/records.js` produces SQL statements for supplied record rows. `buildSeedInput(env = process.env, rows = existing default example rows)` and `buildBadDateSeedInput(env = process.env, rows = badDateLocations)` from `tooling/seed.js` accept supplied rows while keeping the location examples as CLI defaults. Malformed `createdAt` test rows can be built for any record collection with `makeDateCaseRows(baseRecord, { did })` from `tests/fixtures/bad-dates.js`. Convert those rows to SQL with `badDateSeedSql(rows, { disposableTestTarget: true })` only for an approved disposable test target. The guarded `seed:bad-dates` command still seeds only location rows by default. See the dependent PR for the full install and contract-test procedure. +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/package.json b/hypercerts-api/package.json index 5d971fa7..5abe003f 100644 --- a/hypercerts-api/package.json +++ b/hypercerts-api/package.json @@ -5,8 +5,6 @@ "dependencies": { "@atcute/cbor": "2.3.2", "@atcute/cid": "2.4.1", - "@atproto/lexicon": "0.7.4", - "@atproto/syntax": "0.6.4", "@hypercerts-org/lexicon": "1.4.0" }, "scripts": { diff --git a/hypercerts-api/pnpm-lock.yaml b/hypercerts-api/pnpm-lock.yaml index 79a4aaaa..08d916f6 100644 --- a/hypercerts-api/pnpm-lock.yaml +++ b/hypercerts-api/pnpm-lock.yaml @@ -14,12 +14,6 @@ importers: '@atcute/cid': specifier: 2.4.1 version: 2.4.1 - '@atproto/lexicon': - specifier: 0.7.4 - version: 0.7.4 - '@atproto/syntax': - specifier: 0.6.4 - version: 0.6.4 '@hypercerts-org/lexicon': specifier: 1.4.0 version: 1.4.0(@atproto/xrpc@0.8.13) @@ -66,17 +60,9 @@ packages: resolution: {integrity: sha512-hDG2+61JTrvxDFtoojDFxKmZkFVl/lR/jFi82gSN0zNDjr+dlpUGKlG9MoPBkUKK83+Pi+I2jZ55xXboJq6JTA==} engines: {node: '>=22'} - '@atproto/lexicon@0.7.4': - resolution: {integrity: sha512-ulk4RGwMBp4vbkTOZcIwZJeTEPlj3PImOnx9TqxSxMDnBdySxarpd0Aprg7+iAvGyKTsMcrYHpeoLukZaquk0A==} - engines: {node: '>=22'} - '@atproto/syntax@0.5.4': resolution: {integrity: sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw==} - '@atproto/syntax@0.6.4': - resolution: {integrity: sha512-ELgpShRGMF65cvLXcoMCZFL0HBzy67yz/Nlhox3yOtTh120B09KjjvZYXhMysVog3nUJqv2EW5rf1VRYCeM/hw==} - engines: {node: '>=22'} - '@atproto/syntax@0.7.6': resolution: {integrity: sha512-luKQTcWw1H1jLmYvX34ldBqNjz2orF082njmcF9fxbWTqVhsO+TpY0FHRKVPoV7dwL0Y3L16DNz1ma0LFGH25g==} engines: {node: '>=22'} @@ -182,22 +168,10 @@ snapshots: multiformats: 13.4.2 zod: 3.25.76 - '@atproto/lexicon@0.7.4': - dependencies: - '@atproto/common-web': 0.5.12 - '@atproto/syntax': 0.6.4 - multiformats: 13.4.2 - zod: 3.25.76 - '@atproto/syntax@0.5.4': dependencies: tslib: 2.8.1 - '@atproto/syntax@0.6.4': - dependencies: - iso-datestring-validator: 2.2.2 - tslib: 2.8.1 - '@atproto/syntax@0.7.6': dependencies: iso-datestring-validator: 2.2.2 diff --git a/hypercerts-api/tooling/installer-bundle.test.js b/hypercerts-api/tooling/installer-bundle.test.js index ddc70a76..2fdeb47b 100644 --- a/hypercerts-api/tooling/installer-bundle.test.js +++ b/hypercerts-api/tooling/installer-bundle.test.js @@ -67,15 +67,34 @@ test('invalid asset entries and IDs identify the module and asset index', async 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' }], files: { 'schema.json': '{}' }, + 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', dependsOn }] })); + 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 } }], @@ -87,6 +106,14 @@ test('schema-only bundle installs without location handlers or script assets', a 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: [ @@ -132,8 +159,8 @@ test('packagePath Lexicons still load from the installed package', async (t) => 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' }], files: { 'schema.json': '{}' } }, - beta: { assets: [{ id: 'shared.schema', kind: 'lexicon', path: 'schema.json' }], files: { 'schema.json': '{}' } }, + 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/); }); @@ -141,11 +168,11 @@ test('duplicate asset IDs across modules are rejected instead of silently select 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', dependsOn: ['alpha.schema'] }]], + ['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', dependsOn: [dependency] }], files: { 'schema.json': '{}' } }, - beta: { assets: other, files: { 'schema.json': '{}' } }, + 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/); } @@ -153,8 +180,8 @@ test('missing dependencies and cross-module cycles refuse the combined bundle be 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': '{}' } }, - later: { assets: [{ id: 'later.schema', kind: 'lexicon', path: 'schema.json', config: { backfill: false } }], files: { 'schema.json': '{}' } }, + 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: {} } }); diff --git a/hypercerts-api/tooling/installer-core.test.js b/hypercerts-api/tooling/installer-core.test.js index c17f550d..21699b5a 100644 --- a/hypercerts-api/tooling/installer-core.test.js +++ b/hypercerts-api/tooling/installer-core.test.js @@ -64,3 +64,29 @@ test('admin client rejects insecure remote targets before sending a request', () }), /HTTPS/); assert.equal(called, false); }); + +test('admin client preserves action and token_cost when writing lexicons', async () => { + const calls = []; + const admin = createAdminClient({ + baseUrl: 'http://localhost:8080', + cookie: 'session=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.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 index a65c1f32..5aabe266 100644 --- a/hypercerts-api/tooling/installer.js +++ b/hypercerts-api/tooling/installer.js @@ -3,39 +3,44 @@ import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { readLexiconSource } from './lexicon-source.js'; -export function semanticJson(value) { - if (Array.isArray(value)) return value.map(semanticJson); +export function sortJsonKeys(value) { + if (Array.isArray(value)) return value.map(sortJsonKeys); if (value && typeof value === 'object') { return Object.fromEntries(Object.keys(value).sort((a, b) => { if (a < b) return -1; if (a > b) return 1; return 0; - }).map((key) => [key, semanticJson(value[key])])); + }).map((key) => [key, sortJsonKeys(value[key])])); } return value; } export function compareAsset(asset, installed) { if (installed == null) return 'missing'; - if (JSON.stringify(semanticJson(installed.config ?? {})) !== JSON.stringify(semanticJson(asset.config ?? {}))) return 'conflict'; - if (asset.kind === 'lexicon' && JSON.stringify(semanticJson(installed.lexicon_json)) !== JSON.stringify(semanticJson(asset.lexicon_json))) return 'conflict'; + if (JSON.stringify(sortJsonKeys(installed.config ?? {})) !== JSON.stringify(sortJsonKeys(asset.config ?? {}))) return 'conflict'; + if (asset.kind === 'lexicon' && JSON.stringify(sortJsonKeys(installed.lexicon_json)) !== JSON.stringify(sortJsonKeys(asset.lexicon_json))) return 'conflict'; if (asset.kind === 'script' && installed.body !== asset.body) return 'conflict'; return 'unchanged'; } -export function createAdminClient({ baseUrl, cookie, fetchImpl = globalThis.fetch }) { +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 hostname = target.hostname.toLowerCase().replace(/^\[|\]$/g, ''); + 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'); } - async function request(method, route, body) { + return target; +} + +function createAdminRequest(target, cookie, fetchImpl) { + return async function request(method, route, body) { let response; try { response = await fetchImpl(new URL(route, target), { @@ -50,7 +55,12 @@ export function createAdminClient({ baseUrl, cookie, fetchImpl = globalThis.fetc 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(); - } + }; +} + +export function createAdminClient({ baseUrl, cookie, fetchImpl = globalThis.fetch }) { + const target = validateAdminUrl(baseUrl); + const request = createAdminRequest(target, cookie, fetchImpl); return { async read(asset) { const encoded = encodeURIComponent(asset.id); @@ -62,7 +72,13 @@ export function createAdminClient({ baseUrl, cookie, fetchImpl = globalThis.fetc }, 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 }); + 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 }); } @@ -96,8 +112,7 @@ export function orderAssets(assets) { return ordered; } -export async function applyAssets(assets, client) { - const ordered = orderAssets(assets); +async function preflightAssets(ordered, client) { const states = []; for (const asset of ordered) { const installed = await client.read(asset); @@ -105,6 +120,10 @@ export async function applyAssets(assets, client) { if (state === 'conflict') throw new Error(`Refusing ${asset.id}: unexpected installed difference; inspect and resolve manually before retrying`); states.push({ asset, state }); } + return states; +} + +async function writeMissingAssets(states, client) { const changed = []; for (let i = 0; i < states.length; i++) { const { asset, state } = states[i]; @@ -123,10 +142,14 @@ export async function applyAssets(assets, client) { return { changed, unchanged: states.filter((entry) => entry.state === 'unchanged').map(({ asset }) => asset.id) }; } -/** 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. - */ -export async function loadAssets(manifestPath) { +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"] } +async function readBundleManifest(manifestPath) { let manifest; try { manifest = JSON.parse(await readFile(manifestPath, 'utf8')); @@ -136,6 +159,84 @@ export async function loadAssets(manifestPath) { if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest) || !Array.isArray(manifest.modules) || manifest.modules.length === 0) { throw new Error(`Bundle ${manifestPath} must list at least one module manifest in modules; add a module before installing`); } + return manifest; +} + +// 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"] } +// ] +// } +async function readModuleManifest(modulePath, file) { + let module; + try { + module = JSON.parse(await readFile(file, 'utf8')); + } catch (cause) { + throw new Error(`Module ${modulePath} is missing or invalid (${cause.message}); check the bundle's modules list and module manifest`, { cause }); + } + if (!module || typeof module !== 'object' || Array.isArray(module) || !Array.isArray(module.assets)) { + throw new Error(`Module ${modulePath} must declare an assets array; fix its manifest before installing`); + } + return module; +} + +function validateAssetEntry(entry, modulePath, assetIndex, owners) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry) || typeof entry.id !== 'string' || !entry.id.trim() || !['lexicon', 'script'].includes(entry.kind)) { + throw new Error(`Module ${modulePath} assets[${assetIndex}] needs a nonempty string id and kind lexicon or script; fix this asset before installing`); + } + if (entry.dependsOn !== undefined && (!Array.isArray(entry.dependsOn) || entry.dependsOn.some((id) => typeof id !== 'string' || !id.trim()))) { + throw new Error(`Module ${modulePath} assets[${assetIndex}] (${entry.id}) dependsOn must be an array of nonempty string asset IDs; fix its dependencies before installing`); + } + if (owners.has(entry.id)) { + throw new Error(`Duplicate asset ${entry.id} in modules ${owners.get(entry.id)} and ${modulePath}; declare it in one owner only`); + } + owners.set(entry.id, modulePath); + if (!entry.config || typeof entry.config !== 'object' || Array.isArray(entry.config)) { + throw new Error(`Module ${modulePath} assets[${assetIndex}] (${entry.id}) config must be a non-array object; fix its configuration before installing`); + } +} + +async function loadAssetSource(asset, modulePath, root) { + try { + if (asset.kind === 'lexicon') { + asset.lexicon_json = await readLexiconSource(asset, root); + if (asset.lexicon_json?.id !== asset.id) { + const lexiconId = asset.lexicon_json?.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}`); + } + } else { + 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'); + } + } catch (cause) { + throw new Error(`Asset ${asset.id} in module ${modulePath}: source ${asset.path ?? asset.packagePath ?? '(unset)'} is missing, invalid or empty (${cause.message}); fix the declaration or source before installing`, { cause }); + } +} + +async function loadModuleAssets(modulePath, file, owners) { + const module = await readModuleManifest(modulePath, file); + const assets = []; + for (const [assetIndex, entry] of module.assets.entries()) { + validateAssetEntry(entry, modulePath, assetIndex, owners); + const asset = { ...entry }; + await loadAssetSource(asset, modulePath, path.dirname(file)); + assets.push(asset); + } + 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. + */ +export async function loadAssets(manifestPath) { + const manifest = await readBundleManifest(manifestPath); const assets = []; const owners = new Map(); for (const [moduleIndex, modulePath] of manifest.modules.entries()) { @@ -143,44 +244,7 @@ export async function loadAssets(manifestPath) { throw new Error(`Bundle ${manifestPath} modules[${moduleIndex}] must be a nonempty module path; fix the modules list before installing`); } const file = path.resolve(path.dirname(manifestPath), modulePath); - let module; - try { - module = JSON.parse(await readFile(file, 'utf8')); - } catch (cause) { - throw new Error(`Module ${modulePath} is missing or invalid (${cause.message}); check the bundle's modules list and module manifest`, { cause }); - } - if (!module || typeof module !== 'object' || Array.isArray(module) || !Array.isArray(module.assets)) { - throw new Error(`Module ${modulePath} must declare an assets array; fix its manifest before installing`); - } - for (const [assetIndex, entry] of module.assets.entries()) { - if (!entry || typeof entry !== 'object' || Array.isArray(entry) || typeof entry.id !== 'string' || !entry.id.trim() || !['lexicon', 'script'].includes(entry.kind)) { - throw new Error(`Module ${modulePath} assets[${assetIndex}] needs a nonempty string id and kind lexicon or script; fix this asset before installing`); - } - if (entry.dependsOn !== undefined && (!Array.isArray(entry.dependsOn) || entry.dependsOn.some((id) => typeof id !== 'string' || !id.trim()))) { - throw new Error(`Module ${modulePath} assets[${assetIndex}] (${entry.id}) dependsOn must be an array of nonempty string asset IDs; fix its dependencies before installing`); - } - if (owners.has(entry.id)) { - throw new Error(`Duplicate asset ${entry.id} in modules ${owners.get(entry.id)} and ${modulePath}; declare it in one owner only`); - } - owners.set(entry.id, modulePath); - const root = path.dirname(file); - const asset = { ...entry }; - try { - if (entry.kind === 'lexicon') { - asset.lexicon_json = await readLexiconSource(entry, root); - } else { - if (!entry.path) throw new Error('script source path is missing'); - const source = path.resolve(root, entry.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'); - } - } catch (cause) { - throw new Error(`Asset ${entry.id} in module ${modulePath}: source ${entry.path ?? entry.packagePath ?? '(unset)'} is missing, invalid or empty (${cause.message}); fix the declaration or source before installing`, { cause }); - } - assets.push(asset); - } + assets.push(...await loadModuleAssets(modulePath, file, owners)); } orderAssets(assets); return { assets }; From a332d8508dfc93417a4034c1c5b078c428ed7492 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Fri, 25 Sep 2026 21:32:02 +0600 Subject: [PATCH 03/11] hypercerts-api: use admin tokens for installation Signed-off-by: kzoeps --- hypercerts-api/README.md | 4 +- hypercerts-api/tooling/installer-core.test.js | 68 +++++++++++++++---- hypercerts-api/tooling/installer.js | 23 ++++--- 3 files changed, 72 insertions(+), 23 deletions(-) diff --git a/hypercerts-api/README.md b/hypercerts-api/README.md index 2501c588..f2f6c0f2 100644 --- a/hypercerts-api/README.md +++ b/hypercerts-api/README.md @@ -67,7 +67,9 @@ The root manifest lists module manifests once: 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`; it reads `HAPPYVIEW_BASE_URL` and `HAPPYVIEW_SESSION_COOKIE` from the environment. Do not use it until the bundle includes its production manifests, handlers, and domain-specific completeness tests. +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) diff --git a/hypercerts-api/tooling/installer-core.test.js b/hypercerts-api/tooling/installer-core.test.js index 21699b5a..05748d94 100644 --- a/hypercerts-api/tooling/installer-core.test.js +++ b/hypercerts-api/tooling/installer-core.test.js @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; import { applyAssets, createAdminClient, orderAssets } from './installer.js'; test('installer orders dependencies before consumers regardless of manifest order', () => { @@ -44,23 +45,62 @@ test('unchanged assets are skipped and partial failures report completed and rem assert.deepEqual(written, ['new']); }); -test('admin client never forwards cookies across redirects and rejects URL credentials', async () => { - const calls = []; - const admin = createAdminClient({ baseUrl: 'http://localhost:8080', cookie: 'secret', fetchImpl: async (url, options) => { - calls.push({ url: String(url), options }); - throw new Error('redirect disallowed'); - } }); +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.equal(calls[0].options.redirect, 'error'); - assert.equal(calls[0].options.headers.cookie, 'secret'); - assert.throws(() => createAdminClient({ baseUrl: 'https://user:password@example.com', cookie: 'secret' }), /no URL credentials/); + assert.deepEqual(paths, ['/admin/scripts/x']); }); -test('admin client rejects insecure remote targets before sending a request', () => { +test('admin client rejects URL credentials and insecure remote targets before sending a request', () => { let called = false; + const fetchImpl = async () => { called = true; }; + assert.throws(() => createAdminClient({ + baseUrl: 'https://user:password@example.com', token: 'secret', fetchImpl, + }), /no URL credentials/); assert.throws(() => createAdminClient({ - baseUrl: 'http://example.com', cookie: 'private', - fetchImpl: async () => { called = true; }, + baseUrl: 'http://example.com', token: 'secret', fetchImpl, }), /HTTPS/); assert.equal(called, false); }); @@ -69,7 +109,7 @@ test('admin client preserves action and token_cost when writing lexicons', async const calls = []; const admin = createAdminClient({ baseUrl: 'http://localhost:8080', - cookie: 'session=secret', + token: 'hv_write-secret', fetchImpl: async (url, options) => { calls.push({ url: String(url), options }); return { ok: true, status: 200, json: async () => ({}) }; @@ -82,6 +122,8 @@ test('admin client preserves action and token_cost when writing lexicons', async 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, diff --git a/hypercerts-api/tooling/installer.js b/hypercerts-api/tooling/installer.js index 5aabe266..89be78ca 100644 --- a/hypercerts-api/tooling/installer.js +++ b/hypercerts-api/tooling/installer.js @@ -39,13 +39,13 @@ function validateAdminUrl(baseUrl) { return target; } -function createAdminRequest(target, cookie, fetchImpl) { +function createAdminRequest(target, token, fetchImpl) { return async function request(method, route, body) { let response; try { response = await fetchImpl(new URL(route, target), { method, - headers: { cookie, ...(body === undefined ? {} : { 'content-type': 'application/json' }) }, + headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { 'content-type': 'application/json' }) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), redirect: 'error', }); @@ -58,9 +58,12 @@ function createAdminRequest(target, cookie, fetchImpl) { }; } -export function createAdminClient({ baseUrl, cookie, fetchImpl = globalThis.fetch }) { +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, cookie, fetchImpl); + const request = createAdminRequest(target, token.trim(), fetchImpl); return { async read(asset) { const encoded = encodeURIComponent(asset.id); @@ -252,15 +255,17 @@ export async function loadAssets(manifestPath) { function requiredEnv(name) { const value = process.env[name]; - if (!value) throw new Error(`${name} is required; see hypercerts-api/README.md`); - return value; + 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 { assets } = await loadAssets(fileURLToPath(new URL('../manifest.json', import.meta.url))); const baseUrl = new URL(requiredEnv('HAPPYVIEW_BASE_URL')); - const cookie = requiredEnv('HAPPYVIEW_SESSION_COOKIE'); - const client = createAdminClient({ baseUrl, cookie }); + 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)); } From 6b6240e14dbf97acff7bac53f1c09a075c74fde4 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Fri, 25 Sep 2026 22:34:25 +0600 Subject: [PATCH 04/11] hypercerts-api: harden installer tests and finalize package metadata --- hypercerts-api/package.json | 3 +- hypercerts-api/pnpm-lock.yaml | 3 + .../tests/contracts/helpers.test.js | 7 +- .../tests/fixtures/fixtures.test.js | 3 + hypercerts-api/tests/fixtures/records.js | 6 +- hypercerts-api/tooling/installer-cli.test.js | 65 ++++++++++++ hypercerts-api/tooling/installer-core.test.js | 100 ++++++++++++++++-- hypercerts-api/tooling/installer.js | 4 +- hypercerts-api/tooling/seed.test.js | 13 +-- 9 files changed, 176 insertions(+), 28 deletions(-) create mode 100644 hypercerts-api/tooling/installer-cli.test.js diff --git a/hypercerts-api/package.json b/hypercerts-api/package.json index 5abe003f..56dde55f 100644 --- a/hypercerts-api/package.json +++ b/hypercerts-api/package.json @@ -1,10 +1,11 @@ { - "name": "@happyview/hypercerts-api-test-kit", + "name": "@happyview/hypercerts-api-kit", "private": true, "type": "module", "dependencies": { "@atcute/cbor": "2.3.2", "@atcute/cid": "2.4.1", + "@atproto/syntax": "0.5.4", "@hypercerts-org/lexicon": "1.4.0" }, "scripts": { diff --git a/hypercerts-api/pnpm-lock.yaml b/hypercerts-api/pnpm-lock.yaml index 08d916f6..60b56076 100644 --- a/hypercerts-api/pnpm-lock.yaml +++ b/hypercerts-api/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@atcute/cid': specifier: 2.4.1 version: 2.4.1 + '@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) diff --git a/hypercerts-api/tests/contracts/helpers.test.js b/hypercerts-api/tests/contracts/helpers.test.js index 91989b4e..bcf881da 100644 --- a/hypercerts-api/tests/contracts/helpers.test.js +++ b/hypercerts-api/tests/contracts/helpers.test.js @@ -2,9 +2,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { contractUrl, requireContractTarget } from './helpers.js'; -test('repeated arrays are encoded as unbracketed query keys and empty optional arrays are omitted', () => { - assert.equal(contractUrl('http://127.0.0.1:8080', 'app.certified.location.listLocations', { authors: ['did:plc:a', 'did:plc:b'], uris: [], search: 'river bank' }), - 'http://127.0.0.1:8080/xrpc/app.certified.location.listLocations?authors=did%3Aplc%3Aa&authors=did%3Aplc%3Ab&search=river%20bank'); +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', () => { diff --git a/hypercerts-api/tests/fixtures/fixtures.test.js b/hypercerts-api/tests/fixtures/fixtures.test.js index bfd76e2e..8581a494 100644 --- a/hypercerts-api/tests/fixtures/fixtures.test.js +++ b/hypercerts-api/tests/fixtures/fixtures.test.js @@ -1,6 +1,7 @@ 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', () => { @@ -8,6 +9,8 @@ test('fixtures have consistent full AT-URIs, valid DID/TID/CID identifiers, type 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); diff --git a/hypercerts-api/tests/fixtures/records.js b/hypercerts-api/tests/fixtures/records.js index 72126c9b..7cc5fb1f 100644 --- a/hypercerts-api/tests/fixtures/records.js +++ b/hypercerts-api/tests/fixtures/records.js @@ -34,7 +34,7 @@ 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', - description: 'Community forest restoration area', createdAt: '2025-01-01T00:00:00.000Z', + 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', @@ -58,8 +58,8 @@ export const locationRecords = await Promise.all([ }), 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: String.raw`Literal 100%_\path and + spaces` }, - name: String.raw`Literal 100%_\path and + spaces`, description: 'Search metacharacter fixture', createdAt: '2025-01-04T00:00:00+01:00', + 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', 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 index 05748d94..0106aa12 100644 --- a/hypercerts-api/tooling/installer-core.test.js +++ b/hypercerts-api/tooling/installer-core.test.js @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createServer } from 'node:http'; -import { applyAssets, createAdminClient, orderAssets } from './installer.js'; +import { applyAssets, compareAsset, createAdminClient, orderAssets, sortJsonKeys } from './installer.js'; test('installer orders dependencies before consumers regardless of manifest order', () => { const assets = [ @@ -45,6 +45,17 @@ test('unchanged assets are skipped and partial failures report completed and rem 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; }; @@ -93,18 +104,91 @@ test('admin client rejects redirects before forwarding the bearer token', async assert.deepEqual(paths, ['/admin/scripts/x']); }); -test('admin client rejects URL credentials and insecure remote targets before sending a request', () => { +test('admin client rejects unsafe URLs before sending a request or exposing URL details', () => { let called = false; const fetchImpl = async () => { called = true; }; - assert.throws(() => createAdminClient({ - baseUrl: 'https://user:password@example.com', token: 'secret', fetchImpl, - }), /no URL credentials/); - assert.throws(() => createAdminClient({ - baseUrl: 'http://example.com', token: 'secret', fetchImpl, - }), /HTTPS/); + 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 but rejects changed values and script bodies', () => { + const item = { + id: 'schema', kind: 'lexicon', config: { options: { Z: true, a: false } }, + lexicon_json: { defs: { main: { type: 'record', fields: { Z: 'upper', a: 'lower' } } } }, + }; + const installed = { + config: { options: { a: false, Z: true } }, + lexicon_json: { defs: { main: { fields: { a: 'lower', Z: 'upper' }, type: 'record' } } }, + }; + assert.equal(compareAsset(item, installed), 'unchanged'); + assert.equal(compareAsset(item, { ...installed, lexicon_json: { defs: { main: { fields: { a: 'changed', Z: 'upper' }, type: 'record' } } } }), '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({ diff --git a/hypercerts-api/tooling/installer.js b/hypercerts-api/tooling/installer.js index 89be78ca..5b61d7db 100644 --- a/hypercerts-api/tooling/installer.js +++ b/hypercerts-api/tooling/installer.js @@ -17,7 +17,9 @@ export function sortJsonKeys(value) { export function compareAsset(asset, installed) { if (installed == null) return 'missing'; - if (JSON.stringify(sortJsonKeys(installed.config ?? {})) !== JSON.stringify(sortJsonKeys(asset.config ?? {}))) return 'conflict'; + 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' && JSON.stringify(sortJsonKeys(installed.lexicon_json)) !== JSON.stringify(sortJsonKeys(asset.lexicon_json))) return 'conflict'; if (asset.kind === 'script' && installed.body !== asset.body) return 'conflict'; return 'unchanged'; diff --git a/hypercerts-api/tooling/seed.test.js b/hypercerts-api/tooling/seed.test.js index a2f4b6aa..09db31e8 100644 --- a/hypercerts-api/tooling/seed.test.js +++ b/hypercerts-api/tooling/seed.test.js @@ -57,15 +57,6 @@ test('seed input refuses empty row sets instead of generating invalid PREPARE SQ assert.throws(() => buildBadDateSeedInput(safeSeedEnv, []), /at least one.*row/i); }); -test('bad-date seed input accepts non-location rows with eight parameters', () => { - const custom = { ...badDateLocations[0], collection: 'org.hypercerts.custom', uri: 'at://did:web:custom.example/org.hypercerts.custom/badmissing', record: { $type: 'org.hypercerts.custom', createdAt: null } }; - const sql = buildBadDateSeedInput(safeSeedEnv, [custom]); - assert.match(sql, /PREPARE happyview_fixture \(text, text, text, text, jsonb, text, text, text\)/); - assert.equal((sql.match(/^EXECUTE happyview_fixture\(/gm) ?? []).length, 1); - assert.match(sql, /org\.hypercerts\.custom/); - assert.doesNotMatch(sql, /app\.certified\.location/); -}); - 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); @@ -173,7 +164,7 @@ test('seed CLI rejects an absolute path that is not executable', async () => { })); }); -test('bad-date location rows stay isolated from routine fixtures and filters', () => { +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)); @@ -184,8 +175,6 @@ test('bad-date location rows stay isolated from routine fixtures and filters', ( assert.equal(normalDids.has(row.did), false); assert.equal(normalUris.has(row.uri), false); assert.equal(row.record.locationType, 'date-test'); - assert.equal(`${row.record.name} ${row.record.description}`.toLowerCase().includes('community forest'), false); - assert.equal(`${row.record.name} ${row.record.description}`.includes(String.raw`100%_\path`), false); } }); From 2a640f8bde6544c60ef248a83ab68692c5d12fbc Mon Sep 17 00:00:00 2001 From: kzoeps Date: Sat, 26 Sep 2026 18:24:51 +0600 Subject: [PATCH 05/11] repo: add concise agent instructions --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..a0b81091 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,6 @@ +# Agent instructions + +- Read the relevant documentation and existing code before making changes. +- Keep changes scoped to the request; leave unrelated files untouched. +- Use each package’s documented scripts and package manager from that package’s directory. +- Run relevant checks and report the exact commands and results. From f404ea4614365a7b21025fcf50a7a08452e7dd07 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Sat, 26 Sep 2026 19:22:32 +0600 Subject: [PATCH 06/11] agents: document repository map and checks Signed-off-by: kzoeps --- AGENTS.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a0b81091..b143c304 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,14 @@ # Agent instructions -- Read the relevant documentation and existing code before making changes. -- Keep changes scoped to the request; leave unrelated files untouched. -- Use each package’s documented scripts and package manager from that package’s directory. -- Run relevant checks and report the exact commands and results. +## 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`. From b45827e1fe8853853b06e0eadb35ac155454befc Mon Sep 17 00:00:00 2001 From: kzoeps Date: Sat, 26 Sep 2026 19:29:29 +0600 Subject: [PATCH 07/11] hypercerts-api: add JavaScript and Lua lint checks --- .github/workflows/hypercerts-api.yml | 49 ++ hypercerts-api/.luacheckrc | 5 + hypercerts-api/README.md | 6 +- hypercerts-api/eslint.config.js | 32 ++ hypercerts-api/package.json | 9 + hypercerts-api/pnpm-lock.yaml | 631 ++++++++++++++++++++++++ hypercerts-api/tooling/installer.js | 2 + hypercerts-api/tooling/lint-lua.js | 71 +++ hypercerts-api/tooling/lint-lua.test.js | 107 ++++ hypercerts-api/tooling/seed.js | 2 +- hypercerts-api/tooling/seed.test.js | 2 +- 11 files changed, 912 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/hypercerts-api.yml create mode 100644 hypercerts-api/.luacheckrc create mode 100644 hypercerts-api/eslint.config.js create mode 100644 hypercerts-api/tooling/lint-lua.js create mode 100644 hypercerts-api/tooling/lint-lua.test.js diff --git a/.github/workflows/hypercerts-api.yml b/.github/workflows/hypercerts-api.yml new file mode 100644 index 00000000..dc90bb8e --- /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@v7 + + - uses: leafo/gh-actions-lua@v10 + with: + luaVersion: "5.4" + + - uses: leafo/gh-actions-luarocks@v4 + + - name: Install Luacheck + run: luarocks install luacheck 1.2.0 + + - uses: pnpm/action-setup@v4 + with: + version: 10.15.1 + + - uses: actions/setup-node@v7 + 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/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 index f2f6c0f2..0de21dee 100644 --- a/hypercerts-api/README.md +++ b/hypercerts-api/README.md @@ -10,10 +10,12 @@ From `hypercerts-api/`: ```sh pnpm install --frozen-lockfile -pnpm test:unit +pnpm check ``` -These checks run offline; they 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. +`pnpm check` runs the JavaScript and Lua lint checks, then the offline unit tests. 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 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 index 56dde55f..d107001e 100644 --- a/hypercerts-api/package.json +++ b/hypercerts-api/package.json @@ -9,8 +9,17 @@ "@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", + "check": "pnpm run lint && 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", + "eslint": "^10.11.0", + "globals": "^17.12.0" } } diff --git a/hypercerts-api/pnpm-lock.yaml b/hypercerts-api/pnpm-lock.yaml index 60b56076..94868d31 100644 --- a/hypercerts-api/pnpm-lock.yaml +++ b/hypercerts-api/pnpm-lock.yaml @@ -20,6 +20,16 @@ importers: '@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) + eslint: + specifier: ^10.11.0 + version: 10.11.0 + globals: + specifier: ^17.12.0 + version: 17.12.0 packages: @@ -74,29 +84,344 @@ packages: 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==} + + 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'} + uint8arrays@3.0.0: resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==} 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==} @@ -185,24 +510,330 @@ snapshots: '@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': {} + + 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 + uint8arrays@3.0.0: dependencies: multiformats: 9.9.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/tooling/installer.js b/hypercerts-api/tooling/installer.js index 5b61d7db..487f3d6c 100644 --- a/hypercerts-api/tooling/installer.js +++ b/hypercerts-api/tooling/installer.js @@ -203,6 +203,7 @@ function validateAssetEntry(entry, modulePath, assetIndex, owners) { } } +/* eslint-disable no-param-reassign -- the caller passes a private copy to populate with its loaded source. */ async function loadAssetSource(asset, modulePath, root) { try { if (asset.kind === 'lexicon') { @@ -224,6 +225,7 @@ async function loadAssetSource(asset, modulePath, root) { throw new Error(`Asset ${asset.id} in module ${modulePath}: source ${asset.path ?? asset.packagePath ?? '(unset)'} is missing, invalid or empty (${cause.message}); fix the declaration or source before installing`, { cause }); } } +/* eslint-enable no-param-reassign */ async function loadModuleAssets(modulePath, file, owners) { const module = await readModuleManifest(modulePath, file); 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 index 9f74e5d8..88837a7b 100644 --- a/hypercerts-api/tooling/seed.js +++ b/hypercerts-api/tooling/seed.js @@ -59,7 +59,7 @@ function configuredPsqlPath(env) { 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}`); + throw new Error(`PSQL_PATH must point to an existing executable file (${executable}): ${error.message}`, { cause: error }); } return executable; } diff --git a/hypercerts-api/tooling/seed.test.js b/hypercerts-api/tooling/seed.test.js index 09db31e8..2b988674 100644 --- a/hypercerts-api/tooling/seed.test.js +++ b/hypercerts-api/tooling/seed.test.js @@ -78,7 +78,7 @@ test('seed SQL E-escapes fixture backslashes and apostrophes', () => { PGHOST: '127.0.0.1', }); assert.ok(sql.includes("E'")); - assert.ok(sql.includes(`\"name\":\"O''Brien ${'\\\\'.repeat(4)}woods\"`)); + assert.ok(sql.includes(`"name":"O''Brien ${'\\\\'.repeat(4)}woods"`)); } finally { fixture.record.name = originalName; } From 54a227fc73abd26f5891c29e55dbcd8651dd1990 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Sun, 27 Sep 2026 14:38:04 +0600 Subject: [PATCH 08/11] installer,ci: canonicalize refs and pin workflow actions --- .github/workflows/hypercerts-api.yml | 10 ++-- hypercerts-api/package.json | 1 + hypercerts-api/pnpm-lock.yaml | 3 ++ hypercerts-api/tooling/installer-core.test.js | 46 ++++++++++++++++--- hypercerts-api/tooling/installer.js | 18 +++++++- 5 files changed, 66 insertions(+), 12 deletions(-) diff --git a/.github/workflows/hypercerts-api.yml b/.github/workflows/hypercerts-api.yml index dc90bb8e..6b89cc44 100644 --- a/.github/workflows/hypercerts-api.yml +++ b/.github/workflows/hypercerts-api.yml @@ -21,22 +21,22 @@ jobs: run: working-directory: hypercerts-api steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: leafo/gh-actions-lua@v10 + - uses: leafo/gh-actions-lua@6919171ccf181b826f44b9bca76307b577217377 # v13.0.0 with: luaVersion: "5.4" - - uses: leafo/gh-actions-luarocks@v4 + - uses: leafo/gh-actions-luarocks@35d062def313a7699a0d513e996bcf4352f05389 # v6.1.0 - name: Install Luacheck run: luarocks install luacheck 1.2.0 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 10.15.1 - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 cache: pnpm diff --git a/hypercerts-api/package.json b/hypercerts-api/package.json index d107001e..2970374e 100644 --- a/hypercerts-api/package.json +++ b/hypercerts-api/package.json @@ -5,6 +5,7 @@ "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" }, diff --git a/hypercerts-api/pnpm-lock.yaml b/hypercerts-api/pnpm-lock.yaml index 94868d31..7c6bf361 100644 --- a/hypercerts-api/pnpm-lock.yaml +++ b/hypercerts-api/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@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 diff --git a/hypercerts-api/tooling/installer-core.test.js b/hypercerts-api/tooling/installer-core.test.js index 0106aa12..c95bf489 100644 --- a/hypercerts-api/tooling/installer-core.test.js +++ b/hypercerts-api/tooling/installer-core.test.js @@ -152,17 +152,51 @@ test('admin client treats only GET 404 as missing and sanitizes HTTP errors', as assert.deepEqual(calls.map(({ method }) => method), ['GET', 'POST', 'GET']); }); -test('semantic JSON comparison ignores nested key order but rejects changed values and script bodies', () => { +test('semantic JSON comparison ignores nested key order and canonical Lexicon refs, but rejects changed values and script bodies', () => { const item = { - id: 'schema', kind: 'lexicon', config: { options: { Z: true, a: false } }, - lexicon_json: { defs: { main: { type: 'record', fields: { Z: 'upper', a: 'lower' } } } }, + 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: { options: { a: false, Z: true } }, - lexicon_json: { defs: { main: { fields: { a: 'lower', Z: 'upper' }, type: 'record' } } }, + 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(compareAsset(item, { ...installed, lexicon_json: { defs: { main: { fields: { a: 'changed', Z: 'upper' }, type: 'record' } } } }), 'conflict'); + 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'); }); diff --git a/hypercerts-api/tooling/installer.js b/hypercerts-api/tooling/installer.js index 487f3d6c..5b38e684 100644 --- a/hypercerts-api/tooling/installer.js +++ b/hypercerts-api/tooling/installer.js @@ -1,8 +1,11 @@ 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'; +const require = createRequire(new URL('../package.json', import.meta.url)); + export function sortJsonKeys(value) { if (Array.isArray(value)) return value.map(sortJsonKeys); if (value && typeof value === 'object') { @@ -15,12 +18,25 @@ export function sortJsonKeys(value) { return value; } +function canonicalizeLexiconRefs(document) { + // HappyView stores canonical `lex:` refs; package schemas may use unprefixed refs. + const { Lexicons } = require('@atproto/lexicon'); + const canonical = structuredClone(document); + const lexicons = new Lexicons([canonical]); + return lexicons.get(canonical.id); +} + 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' && JSON.stringify(sortJsonKeys(installed.lexicon_json)) !== JSON.stringify(sortJsonKeys(asset.lexicon_json))) 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'; } From 0f2da811c62c156a5f84a24fbf1c338fa28b58f0 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Mon, 28 Sep 2026 22:42:47 +0600 Subject: [PATCH 09/11] hypercerts-api: typecheck installer contracts with JSDoc --- hypercerts-api/README.md | 2 +- hypercerts-api/package.json | 7 +- hypercerts-api/pnpm-lock.yaml | 25 +++++ hypercerts-api/tooling/installer.js | 120 ++++++++++++++++++----- hypercerts-api/tooling/lexicon-source.js | 7 +- hypercerts-api/tsconfig.json | 14 +++ 6 files changed, 143 insertions(+), 32 deletions(-) create mode 100644 hypercerts-api/tsconfig.json diff --git a/hypercerts-api/README.md b/hypercerts-api/README.md index 0de21dee..fa3f26fa 100644 --- a/hypercerts-api/README.md +++ b/hypercerts-api/README.md @@ -13,7 +13,7 @@ pnpm install --frozen-lockfile pnpm check ``` -`pnpm check` runs the JavaScript and Lua lint checks, then the offline unit tests. 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. +`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. diff --git a/hypercerts-api/package.json b/hypercerts-api/package.json index 2970374e..7b2b3f70 100644 --- a/hypercerts-api/package.json +++ b/hypercerts-api/package.json @@ -13,14 +13,17 @@ "lint:js": "eslint --max-warnings=0 .", "lint:lua": "node tooling/lint-lua.js", "lint": "pnpm run lint:js && pnpm run lint:lua", - "check": "pnpm run lint && pnpm run test:unit", + "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" + "globals": "^17.12.0", + "typescript": "5.9.3" } } diff --git a/hypercerts-api/pnpm-lock.yaml b/hypercerts-api/pnpm-lock.yaml index 7c6bf361..994b155c 100644 --- a/hypercerts-api/pnpm-lock.yaml +++ b/hypercerts-api/pnpm-lock.yaml @@ -27,12 +27,18 @@ importers: '@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: @@ -175,6 +181,9 @@ packages: '@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: @@ -403,9 +412,17 @@ packages: 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==} @@ -595,6 +612,10 @@ snapshots: '@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 @@ -821,10 +842,14 @@ snapshots: 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: diff --git a/hypercerts-api/tooling/installer.js b/hypercerts-api/tooling/installer.js index 5b38e684..e195a86d 100644 --- a/hypercerts-api/tooling/installer.js +++ b/hypercerts-api/tooling/installer.js @@ -4,28 +4,54 @@ import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { readLexiconSource } from './lexicon-source.js'; +/** @typedef {Record} AssetConfig */ +/** @typedef {{ modules: unknown[] }} BundleManifest */ +/** @typedef {{ assets: unknown[] }} ModuleManifest */ +/** + * Manifest fields proven by validateAssetEntry. Source paths and option values + * remain unknown because that runtime validator does not inspect them. + * @typedef {{ id: string; kind: 'lexicon' | 'script'; config: AssetConfig; dependsOn?: string[]; path?: unknown; packagePath?: unknown }} ValidatedManifestAsset + */ +/** @typedef {ValidatedManifestAsset & { kind: 'lexicon'; lexicon_json: unknown }} LoadedLexiconAsset */ +/** @typedef {ValidatedManifestAsset & { kind: 'script'; 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(value[key])])); + }).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'); - const canonical = structuredClone(document); + // 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 ?? {}; @@ -41,6 +67,7 @@ export function compareAsset(asset, installed) { return 'unchanged'; } +/** @param {string | URL} baseUrl @returns {URL} */ function validateAdminUrl(baseUrl) { let target; try { @@ -57,8 +84,9 @@ function validateAdminUrl(baseUrl) { return target; } +/** @param {URL} target @param {string} token @param {typeof globalThis.fetch} fetchImpl */ function createAdminRequest(target, token, fetchImpl) { - return async function request(method, route, body) { + 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), { @@ -76,6 +104,7 @@ function createAdminRequest(target, token, fetchImpl) { }; } +/** @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)'); @@ -83,14 +112,19 @@ export function createAdminClient({ baseUrl, token, fetchImpl = globalThis.fetch 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; - return asset.kind === 'lexicon' - ? { config: { backfill: row.backfill, target_collection: row.target_collection ?? undefined, action: row.action ?? undefined, token_cost: row.token_cost ?? undefined }, lexicon_json: row.lexicon_json } - : { config: { script_type: row.script_type, description: row.description ?? undefined }, body: row.body }; + 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', { @@ -107,15 +141,23 @@ export function createAdminClient({ baseUrl, token, fetchImpl = globalThis.fetch }; } +/** + * @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`); @@ -133,6 +175,7 @@ export function orderAssets(assets) { return ordered; } +/** @param {LoadedAsset[]} ordered @param {AdminClient} client @returns {Promise} */ async function preflightAssets(ordered, client) { const states = []; for (const asset of ordered) { @@ -144,6 +187,7 @@ async function preflightAssets(ordered, client) { 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++) { @@ -154,7 +198,7 @@ async function writeMissingAssets(states, client) { changed.push(asset.id); } catch (cause) { const reason = cause instanceof Error ? cause.message : 'unknown write failure'; - const error = 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 }); + 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; @@ -163,6 +207,7 @@ async function writeMissingAssets(states, client) { 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); @@ -170,12 +215,15 @@ export async function applyAssets(assets, 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 manifest; try { manifest = JSON.parse(await readFile(manifestPath, 'utf8')); } catch (cause) { - throw new Error(`Bundle ${manifestPath} is missing or invalid (${cause.message}); create or fix the root manifest before installing`, { 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 (!manifest || typeof manifest !== 'object' || Array.isArray(manifest) || !Array.isArray(manifest.modules) || manifest.modules.length === 0) { throw new Error(`Bundle ${manifestPath} must list at least one module manifest in modules; add a module before installing`); @@ -190,12 +238,15 @@ async function readBundleManifest(manifestPath) { // { "id": "org.example.handler", "kind": "script", "path": "handler.lua", "config": { "script_type": "lua" }, "dependsOn": ["org.example.schema"] } // ] // } +/** @param {string} modulePath @param {string} file @returns {Promise} */ async function readModuleManifest(modulePath, file) { let module; try { module = JSON.parse(await readFile(file, 'utf8')); } catch (cause) { - throw new Error(`Module ${modulePath} is missing or invalid (${cause.message}); check the bundle's modules list and module manifest`, { 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 (!module || typeof module !== 'object' || Array.isArray(module) || !Array.isArray(module.assets)) { throw new Error(`Module ${modulePath} must declare an assets array; fix its manifest before installing`); @@ -203,64 +254,77 @@ async function readModuleManifest(modulePath, file) { return module; } +/** @param {unknown} entry @param {string} modulePath @param {number} assetIndex @param {Map} owners @returns {ValidatedManifestAsset} */ function validateAssetEntry(entry, modulePath, assetIndex, owners) { - if (!entry || typeof entry !== 'object' || Array.isArray(entry) || typeof entry.id !== 'string' || !entry.id.trim() || !['lexicon', 'script'].includes(entry.kind)) { + const candidate = /** @type {Record} */ (entry); + if (!entry || typeof entry !== 'object' || Array.isArray(entry) || 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`); } - if (entry.dependsOn !== undefined && (!Array.isArray(entry.dependsOn) || entry.dependsOn.some((id) => typeof id !== 'string' || !id.trim()))) { - throw new Error(`Module ${modulePath} assets[${assetIndex}] (${entry.id}) dependsOn must be an array of nonempty string asset IDs; fix its dependencies before installing`); + 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`); } - if (owners.has(entry.id)) { - throw new Error(`Duplicate asset ${entry.id} in modules ${owners.get(entry.id)} and ${modulePath}; declare it in one owner only`); + if (owners.has(candidate.id)) { + throw new Error(`Duplicate asset ${candidate.id} in modules ${owners.get(candidate.id)} and ${modulePath}; declare it in one owner only`); } - owners.set(entry.id, modulePath); - if (!entry.config || typeof entry.config !== 'object' || Array.isArray(entry.config)) { - throw new Error(`Module ${modulePath} assets[${assetIndex}] (${entry.id}) config must be a non-array object; fix its configuration before installing`); + owners.set(candidate.id, modulePath); + 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`); } + return /** @type {ValidatedManifestAsset} */ (candidate); } +/** @param {ValidatedManifestAsset & { lexicon_json?: unknown; body?: string }} asset @param {string} modulePath @param {string} root @returns {Promise} */ /* eslint-disable no-param-reassign -- the caller passes a private copy to populate with its loaded source. */ async function loadAssetSource(asset, modulePath, root) { try { if (asset.kind === 'lexicon') { asset.lexicon_json = await readLexiconSource(asset, root); - if (asset.lexicon_json?.id !== asset.id) { - const lexiconId = asset.lexicon_json?.id; + 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}`); } } else { if (!asset.path) throw new Error('script source path is missing'); - const source = path.resolve(root, asset.path); + const source = path.resolve(root, /** @type {string} */ (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'); } } catch (cause) { - throw new Error(`Asset ${asset.id} in module ${modulePath}: source ${asset.path ?? asset.packagePath ?? '(unset)'} is missing, invalid or empty (${cause.message}); fix the declaration or source before installing`, { 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 ${asset.path ?? asset.packagePath ?? '(unset)'} is missing, invalid or empty (${detail}); fix the declaration or source before installing`, { cause }); } + return /** @type {LoadedAsset} */ (asset); } /* eslint-enable no-param-reassign */ +/** @param {string} modulePath @param {string} file @param {Map} owners @returns {Promise} */ async function loadModuleAssets(modulePath, file, owners) { const module = await readModuleManifest(modulePath, file); + /** @type {LoadedAsset[]} */ const assets = []; for (const [assetIndex, entry] of module.assets.entries()) { - validateAssetEntry(entry, modulePath, assetIndex, owners); - const asset = { ...entry }; - await loadAssetSource(asset, modulePath, path.dirname(file)); - assets.push(asset); + const validatedAsset = validateAssetEntry(entry, modulePath, assetIndex, owners); + const asset = { ...validatedAsset }; + assets.push(await loadAssetSource(asset, modulePath, path.dirname(file))); } return assets; } -/** Load one bundle of module manifests, resolving local sources relative to each module. +/** + * 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 [moduleIndex, modulePath] of manifest.modules.entries()) { if (typeof modulePath !== 'string' || !modulePath.trim()) { @@ -273,6 +337,7 @@ export async function loadAssets(manifestPath) { return { assets }; } +/** @param {string} name @returns {string} */ function requiredEnv(name) { const value = process.env[name]; if (typeof value !== 'string' || !value.trim()) { @@ -294,7 +359,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.ar try { await main(); } catch (error) { - console.error(error.message); + // 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 index aa92ef38..f7d974d7 100644 --- a/hypercerts-api/tooling/lexicon-source.js +++ b/hypercerts-api/tooling/lexicon-source.js @@ -5,9 +5,12 @@ 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?: unknown; path?: unknown }} 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, source.packagePath) - : path.join(root, source.path); + ? 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/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"] +} From 6d5b742a43ba5a148f17704e6504abea00487749 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Tue, 29 Sep 2026 12:38:31 +0600 Subject: [PATCH 10/11] hypercerts-api: validate and type installer manifests --- .../tooling/installer-bundle.test.js | 75 +++++++++ hypercerts-api/tooling/installer.js | 146 +++++++++++++----- hypercerts-api/tooling/lexicon-source.js | 2 +- 3 files changed, 187 insertions(+), 36 deletions(-) diff --git a/hypercerts-api/tooling/installer-bundle.test.js b/hypercerts-api/tooling/installer-bundle.test.js index 2fdeb47b..5e4f9c96 100644 --- a/hypercerts-api/tooling/installer-bundle.test.js +++ b/hypercerts-api/tooling/installer-bundle.test.js @@ -50,12 +50,87 @@ test('module paths must be nonempty strings and identify their index', async (t) } }); +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'); diff --git a/hypercerts-api/tooling/installer.js b/hypercerts-api/tooling/installer.js index e195a86d..fdb10ae9 100644 --- a/hypercerts-api/tooling/installer.js +++ b/hypercerts-api/tooling/installer.js @@ -4,16 +4,17 @@ import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { readLexiconSource } from './lexicon-source.js'; -/** @typedef {Record} AssetConfig */ -/** @typedef {{ modules: unknown[] }} BundleManifest */ -/** @typedef {{ assets: unknown[] }} ModuleManifest */ -/** - * Manifest fields proven by validateAssetEntry. Source paths and option values - * remain unknown because that runtime validator does not inspect them. - * @typedef {{ id: string; kind: 'lexicon' | 'script'; config: AssetConfig; dependsOn?: string[]; path?: unknown; packagePath?: unknown }} ValidatedManifestAsset - */ -/** @typedef {ValidatedManifestAsset & { kind: 'lexicon'; lexicon_json: unknown }} LoadedLexiconAsset */ -/** @typedef {ValidatedManifestAsset & { kind: 'script'; path: string; body: string }} LoadedScriptAsset */ +/** @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 */ @@ -217,18 +218,30 @@ export async function applyAssets(assets, client) { // { "modules": ["modules/shared/manifest.json", "modules/location/manifest.json"] } /** @param {string} manifestPath @returns {Promise} */ async function readBundleManifest(manifestPath) { - let manifest; + let parsed; try { - manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + 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 (!manifest || typeof manifest !== 'object' || Array.isArray(manifest) || !Array.isArray(manifest.modules) || manifest.modules.length === 0) { + 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`); } - return manifest; + 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): @@ -238,38 +251,102 @@ async function readBundleManifest(manifestPath) { // { "id": "org.example.handler", "kind": "script", "path": "handler.lua", "config": { "script_type": "lua" }, "dependsOn": ["org.example.schema"] } // ] // } -/** @param {string} modulePath @param {string} file @returns {Promise} */ -async function readModuleManifest(modulePath, file) { - let module; +/** @param {string} modulePath @param {string} file @param {Map} owners @returns {Promise} */ +async function readModuleManifest(modulePath, file, owners) { + let parsed; try { - module = JSON.parse(await readFile(file, 'utf8')); + 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 (!module || typeof module !== 'object' || Array.isArray(module) || !Array.isArray(module.assets)) { + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error(`Module ${modulePath} must declare an assets array; fix its manifest before installing`); } - return module; + 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 @param {Map} owners @returns {ValidatedManifestAsset} */ -function validateAssetEntry(entry, modulePath, assetIndex, owners) { +/** @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 (!entry || typeof entry !== 'object' || Array.isArray(entry) || typeof candidate.id !== 'string' || !candidate.id.trim() || (candidate.kind !== 'lexicon' && candidate.kind !== 'script')) { + 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`); } - if (owners.has(candidate.id)) { - throw new Error(`Duplicate asset ${candidate.id} in modules ${owners.get(candidate.id)} and ${modulePath}; declare it in one owner only`); +} + +/** @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(candidate.id, modulePath); + 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); } @@ -295,7 +372,10 @@ async function loadAssetSource(asset, modulePath, 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 ${asset.path ?? asset.packagePath ?? '(unset)'} is missing, invalid or empty (${detail}); fix the declaration or source before installing`, { cause }); + const sourcePath = typeof asset.path === 'string' + ? asset.path + : typeof asset.packagePath === 'string' ? asset.packagePath : '(unset)'; + throw new Error(`Asset ${asset.id} in module ${modulePath}: source ${sourcePath} is missing, invalid or empty (${detail}); fix the declaration or source before installing`, { cause }); } return /** @type {LoadedAsset} */ (asset); } @@ -303,12 +383,11 @@ async function loadAssetSource(asset, modulePath, root) { /** @param {string} modulePath @param {string} file @param {Map} owners @returns {Promise} */ async function loadModuleAssets(modulePath, file, owners) { - const module = await readModuleManifest(modulePath, file); + const module = await readModuleManifest(modulePath, file, owners); /** @type {LoadedAsset[]} */ const assets = []; - for (const [assetIndex, entry] of module.assets.entries()) { - const validatedAsset = validateAssetEntry(entry, modulePath, assetIndex, owners); - const asset = { ...validatedAsset }; + for (const entry of module.assets) { + const asset = { ...entry }; assets.push(await loadAssetSource(asset, modulePath, path.dirname(file))); } return assets; @@ -326,10 +405,7 @@ export async function loadAssets(manifestPath) { const assets = []; /** @type {Map} */ const owners = new Map(); - for (const [moduleIndex, modulePath] of manifest.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`); - } + for (const modulePath of manifest.modules) { const file = path.resolve(path.dirname(manifestPath), modulePath); assets.push(...await loadModuleAssets(modulePath, file, owners)); } diff --git a/hypercerts-api/tooling/lexicon-source.js b/hypercerts-api/tooling/lexicon-source.js index f7d974d7..c7174bf5 100644 --- a/hypercerts-api/tooling/lexicon-source.js +++ b/hypercerts-api/tooling/lexicon-source.js @@ -5,7 +5,7 @@ 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?: unknown; path?: unknown }} LexiconSource */ +/** @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. From 6a22f7f81f5d3366fd1c21fd3b48d56890ed970c Mon Sep 17 00:00:00 2001 From: kzoeps Date: Tue, 29 Sep 2026 14:19:36 +0600 Subject: [PATCH 11/11] hypercerts-api: simplify asset source loading --- hypercerts-api/tooling/installer.js | 50 ++++++++++++++++++----------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/hypercerts-api/tooling/installer.js b/hypercerts-api/tooling/installer.js index fdb10ae9..8f54c123 100644 --- a/hypercerts-api/tooling/installer.js +++ b/hypercerts-api/tooling/installer.js @@ -350,36 +350,50 @@ function validateAssetEntry(entry, modulePath, assetIndex, owners) { return /** @type {ValidatedManifestAsset} */ (candidate); } -/** @param {ValidatedManifestAsset & { lexicon_json?: unknown; body?: string }} asset @param {string} modulePath @param {string} root @returns {Promise} */ /* 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') { - 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}`); - } + await loadLexiconAssetSource(asset, root); } else { - if (!asset.path) throw new Error('script source path is missing'); - const source = path.resolve(root, /** @type {string} */ (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'); + 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; - const sourcePath = typeof asset.path === 'string' - ? asset.path - : typeof asset.packagePath === 'string' ? asset.packagePath : '(unset)'; - throw new Error(`Asset ${asset.id} in module ${modulePath}: source ${sourcePath} is missing, invalid or empty (${detail}); fix the declaration or source before installing`, { cause }); + 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); } -/* eslint-enable no-param-reassign */ /** @param {string} modulePath @param {string} file @param {Map} owners @returns {Promise} */ async function loadModuleAssets(modulePath, file, owners) {