From 592ca00b35d3685386d3b5863d1d65ed9e3b6365 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Thu, 24 Sep 2026 12:29:45 +0600 Subject: [PATCH 1/4] tooling: add conflict-safe Hypercerts API installer foundation --- hypercerts-api/README.md | 5 + hypercerts-api/package.json | 14 ++ hypercerts-api/pnpm-lock.yaml | 207 ++++++++++++++++++ hypercerts-api/tooling/installer-core.test.js | 35 +++ hypercerts-api/tooling/installer.js | 174 +++++++++++++++ hypercerts-api/tooling/lexicon-source.js | 13 ++ 6 files changed, 448 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/tooling/installer-core.test.js create mode 100644 hypercerts-api/tooling/installer.js create mode 100644 hypercerts-api/tooling/lexicon-source.js diff --git a/hypercerts-api/README.md b/hypercerts-api/README.md new file mode 100644 index 00000000..006a6925 --- /dev/null +++ b/hypercerts-api/README.md @@ -0,0 +1,5 @@ +# Hypercerts API installer foundation + +This package provides the conflict-safe HappyView admin installer and pinned Lexicon dependencies used by the location API. 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. + +From this directory, install dependencies with `pnpm install --frozen-lockfile`, then run offline checks with `pnpm test:unit`. The installer reads existing assets first, refuses unexpected differences, and writes missing assets in dependency order. Installing assets requires the complete location package and an explicitly approved HappyView target. diff --git a/hypercerts-api/package.json b/hypercerts-api/package.json new file mode 100644 index 00000000..0761b93b --- /dev/null +++ b/hypercerts-api/package.json @@ -0,0 +1,14 @@ +{ + "name": "@happyview/hypercerts-api-test-kit", + "private": true, + "type": "module", + "dependencies": { + "@hypercerts-org/lexicon": "1.4.0", + "@atproto/lex-cbor": "0.0.16", + "@atproto/lexicon": "0.7.4", + "@atproto/syntax": "0.6.4" + }, + "scripts": { + "test:unit": "node --test tooling/*.test.js" + } +} diff --git a/hypercerts-api/pnpm-lock.yaml b/hypercerts-api/pnpm-lock.yaml new file mode 100644 index 00000000..16e66805 --- /dev/null +++ b/hypercerts-api/pnpm-lock.yaml @@ -0,0 +1,207 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@atproto/lex-cbor': + specifier: 0.0.16 + version: 0.0.16 + '@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: + + '@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-cbor@0.0.16': + resolution: {integrity: sha512-x3NTvOX5/4Wh7uk8RNJpUJqZjcWRSUYDYRJ6VZPLmp/CAnsMySmBAinBu/dva/1hqS3C1oiYz258x6bRYpKJ4w==} + + '@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: + + '@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-cbor@0.0.16': + dependencies: + '@atproto/lex-data': 0.0.15 + tslib: 2.8.1 + + '@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/tooling/installer-core.test.js b/hypercerts-api/tooling/installer-core.test.js new file mode 100644 index 00000000..dea51cfb --- /dev/null +++ b/hypercerts-api/tooling/installer-core.test.js @@ -0,0 +1,35 @@ +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/); + assert.throws(() => orderAssets([{ id: 'a', dependsOn: ['missing'] }]), /Missing asset dependency/); +}); + +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('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..926fc6fd --- /dev/null +++ b/hypercerts-api/tooling/installer.js @@ -0,0 +1,174 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { existsSync, statSync } from 'node:fs'; +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 assertInstallable(manifest, root = process.cwd()) { + const requiredHandlers = ['getLocation', 'listLocations']; + const pending = requiredHandlers.filter((name) => manifest.handlerStatus?.[name] !== 'implemented'); + if (pending.length) throw new Error(`Cannot apply location package until real Lua handlers exist: ${pending.join(', ')}`); + const scripts = new Map((manifest.assets ?? []).filter(({ kind }) => kind === 'script').map((asset) => [asset.id, asset])); + for (const name of requiredHandlers) { + const id = `xrpc.query:app.certified.location.${name}`; + const asset = scripts.get(id); + const source = asset?.path && root ? path.resolve(root, asset.path) : undefined; + if (!source || !existsSync(source) || !statSync(source).isFile() || statSync(source).size === 0) { + throw new Error(`Cannot apply location package: real Lua handler source is missing for ${id}`); + } + } +} + +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(assets.map((asset) => [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}`); + 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}`); + 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) }; +} + +async function loadAssets(manifestPath) { + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + const root = path.dirname(manifestPath); + assertInstallable(manifest, root); + const assets = await Promise.all(manifest.assets.map(async (entry) => { + const asset = { ...entry }; + if (entry.kind === 'lexicon') asset.lexicon_json = await readLexiconSource(entry, root); + else asset.body = await readFile(path.join(root, entry.path), 'utf8'); + return asset; + })); + return { manifest, root, 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 { manifest, root, assets } = await loadAssets(fileURLToPath(new URL('../manifest.json', import.meta.url))); + assertInstallable(manifest, root); + 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')); +} From fd169f42e04c516ac00a8495201bbf75a30b1bac Mon Sep 17 00:00:00 2001 From: kzoeps Date: Thu, 24 Sep 2026 12:31:10 +0600 Subject: [PATCH 2/4] tooling: add guarded location fixtures and offline seed checks --- hypercerts-api/README.md | 4 +- hypercerts-api/package.json | 4 +- hypercerts-api/tests/contracts/helpers.js | 18 ++ .../tests/contracts/helpers.test.js | 14 ++ .../tests/fixtures/bad-location-dates.js | 46 ++++ .../tests/fixtures/bad-location-dates.test.js | 37 +++ .../tests/fixtures/fixtures.test.js | 54 +++++ hypercerts-api/tests/fixtures/records.js | 87 +++++++ hypercerts-api/tooling/seed.js | 86 +++++++ hypercerts-api/tooling/seed.test.js | 216 ++++++++++++++++++ 10 files changed, 563 insertions(+), 3 deletions(-) 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-location-dates.js create mode 100644 hypercerts-api/tests/fixtures/bad-location-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/seed.js create mode 100644 hypercerts-api/tooling/seed.test.js diff --git a/hypercerts-api/README.md b/hypercerts-api/README.md index 006a6925..cc293972 100644 --- a/hypercerts-api/README.md +++ b/hypercerts-api/README.md @@ -1,5 +1,5 @@ # Hypercerts API installer foundation -This package provides the conflict-safe HappyView admin installer and pinned Lexicon dependencies used by the location API. 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 package provides the conflict-safe HappyView admin installer, pinned Lexicon dependencies, deterministic location fixtures, and guarded seeding tools used by the location API. 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. -From this directory, install dependencies with `pnpm install --frozen-lockfile`, then run offline checks with `pnpm test:unit`. The installer reads existing assets first, refuses unexpected differences, and writes missing assets in dependency order. Installing assets requires the complete location package and an explicitly approved HappyView target. +From this directory, install dependencies with `pnpm install --frozen-lockfile`, then run offline checks with `pnpm test:unit`. The installer reads existing assets first, refuses unexpected differences, and writes missing assets in dependency order. Installing assets requires the complete location package and an explicitly approved HappyView target. Seeding bypasses ingestion and is only for a separately approved, disposable loopback PostgreSQL test database; never use these fixtures on persistent data. See the dependent PR for the full install and contract-test procedure. diff --git a/hypercerts-api/package.json b/hypercerts-api/package.json index 0761b93b..4a0adf0b 100644 --- a/hypercerts-api/package.json +++ b/hypercerts-api/package.json @@ -9,6 +9,8 @@ "@atproto/syntax": "0.6.4" }, "scripts": { - "test:unit": "node --test tooling/*.test.js" + "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/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-location-dates.js b/hypercerts-api/tests/fixtures/bad-location-dates.js new file mode 100644 index 00000000..807ee1a0 --- /dev/null +++ b/hypercerts-api/tests/fixtures/bad-location-dates.js @@ -0,0 +1,46 @@ +// Deliberately invalid record dates: use only with an explicitly disposable PostgreSQL test target. +// These bypass normal Lexicon validation and must never be added to records.js or routine seeding. +import { cidForLex } from '@atproto/lex-cbor'; +import { jsonToLex } from '@atproto/lexicon'; +import { locationRecords } from './records.js'; + +const base = locationRecords[0]; +const did = 'did:web:bad-date-fixtures.example'; +const collection = base.collection; +const prefix = `at://${did}/${collection}/`; + +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 const badDateLocations = await Promise.all(cases.map(async ([rkey, createdAt, indexedAt]) => { + const record = { ...base.record, locationType: 'date-test', name: `Date test ${rkey}`, description: 'Synthetic timestamp fixture' }; + if (createdAt === undefined) delete record.createdAt; + else record.createdAt = createdAt; + return { + uri: prefix + rkey, did, collection, rkey, record, indexedAt, + storedAt: '2025-01-02T03:04:05.123456Z', + cid: (await cidForLex(jsonToLex(record))).toString(), + }; +})); + +export function badDateSeedSql({ disposableTestTarget = false } = {}) { + if (disposableTestTarget !== true) throw new Error('Bad-date seeding requires explicit disposable-test target opt-in'); + return badDateLocations.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-location-dates.test.js b/hypercerts-api/tests/fixtures/bad-location-dates.test.js new file mode 100644 index 00000000..c3108fd5 --- /dev/null +++ b/hypercerts-api/tests/fixtures/bad-location-dates.test.js @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { badDateLocations, badDateSeedSql } from './bad-location-dates.js'; +import { locationRecords, profileRecords, organizationRecords } from './records.js'; + +test('bad-date rows require explicit disposable-target opt-in and preserve malformed JSON values', () => { + assert.throws(() => badDateSeedSql(), /disposable-test target opt-in/); + const statements = badDateSeedSql({ disposableTestTarget: true }); + assert.equal(statements.length, badDateLocations.length); + const missing = statements.find(({ params }) => params[3] === 'badmissing'); + const absent = JSON.parse(missing.params[4]); + assert.equal(Object.hasOwn(absent, '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'); +}); + +test('bad-date fixtures have their own author and do not match normal fixture 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, badDateLocations.length); + for (const row of badDateLocations) { + assert.equal(row.did, 'did:web:bad-date-fixtures.example'); + 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); + } + const statements = badDateSeedSql({ disposableTestTarget: true }); + for (let i = 0; i < statements.length; i++) { + assert.equal(statements[i].params[0], badDateLocations[i].uri); + assert.equal(statements[i].params[1], badDateLocations[i].did); + } +}); diff --git a/hypercerts-api/tests/fixtures/fixtures.test.js b/hypercerts-api/tests/fixtures/fixtures.test.js new file mode 100644 index 00000000..61e3e853 --- /dev/null +++ b/hypercerts-api/tests/fixtures/fixtures.test.js @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { locationRecords, profileRecords, organizationRecords, seedSql } from './records.js'; + +function decodeCid(cid) { + const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; + let bits = 0; + let value = 0; + const bytes = []; + for (const character of cid.slice(1)) { + value = (value << 5) | alphabet.indexOf(character); + bits += 5; + if (bits >= 8) { + bytes.push((value >>> (bits - 8)) & 255); + bits -= 8; + } + } + return Buffer.from(bytes); +} + +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 cidBytes = decodeCid(row.cid); + assert.equal(cidBytes.length, 36); + assert.deepEqual([...cidBytes.subarray(0, 4)], [1, 0x71, 0x12, 0x20]); + 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.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', () => { + assert.throws(() => seedSql({}), /explicit disposable-test target opt-in/); + const statements = seedSql({ disposableTestTarget: true }); + assert.ok(statements.length > 0); + 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:\/\//); + } +}); diff --git a/hypercerts-api/tests/fixtures/records.js b/hypercerts-api/tests/fixtures/records.js new file mode 100644 index 00000000..31ad150d --- /dev/null +++ b/hypercerts-api/tests/fixtures/records.js @@ -0,0 +1,87 @@ +import { cidForLex } from '@atproto/lex-cbor'; +import { jsonToLex } from '@atproto/lexicon'; + +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 = (await cidForLex(jsonToLex(record))).toString(); + 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({ disposableTestTarget = false } = {}) { + if (disposableTestTarget !== true) throw new Error('Seeding requires explicit disposable-test target opt-in'); + return [...locationRecords, ...profileRecords, ...organizationRecords].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/seed.js b/hypercerts-api/tooling/seed.js new file mode 100644 index 00000000..7ef59092 --- /dev/null +++ b/hypercerts-api/tooling/seed.js @@ -0,0 +1,86 @@ +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 } from '../tests/fixtures/records.js'; +import { badDateSeedSql } from '../tests/fixtures/bad-location-dates.js'; + +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_location_fixture (${types}) AS ${statements[0].sql};`, + ...statements.map(({ params }) => `EXECUTE happyview_location_fixture(${params.map(quote).join(', ')});`), + 'DEALLOCATE happyview_location_fixture;', + ].join('\n'); +} + +export function buildSeedInput(env = process.env) { + disposableTarget(env); + return sqlInput(seedSql({ disposableTestTarget: true })); +} + +export function buildBadDateSeedInput(env = process.env) { + disposableTarget(env); + return sqlInput(badDateSeedSql({ 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..27aa4e30 --- /dev/null +++ b/hypercerts-api/tooling/seed.test.js @@ -0,0 +1,216 @@ +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 } from '../tests/fixtures/records.js'; +import { badDateLocations } from '../tests/fixtures/bad-location-dates.js'; +import { 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-location-dates.js', import.meta.url), path.join(root, 'tests/fixtures/bad-location-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 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_location_fixture/); + assert.match(sql, /ON CONFLICT \(uri\) DO UPDATE/); + assert.doesNotMatch(sql, /DELETE|TRUNCATE|DROP/i); + assert.match(sql, /EXECUTE happyview_location_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 seed input uses eight parameters only for the fourteen isolated fixtures', async () => { + const { buildBadDateSeedInput } = await import('./seed.js'); + const sql = buildBadDateSeedInput(safeSeedEnv); + assert.match(sql, /PREPARE happyview_location_fixture \(text, text, text, text, jsonb, text, text, text\)/); + assert.match(sql, /\$7, \$8\) ON CONFLICT/); + assert.equal((sql.match(/^EXECUTE happyview_location_fixture\(/gm) ?? []).length, 14); + assert.match(sql, /bad-date-fixtures\.example/); + 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_location_fixture \(text, text, text, text, jsonb, text, text\)/); + assert.doesNotMatch(normal, /bad-date-fixtures\.example/); +}); + +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); + assert.doesNotMatch(await readFile(capture, 'utf8'), /bad-date-fixtures\.example/); + const bad = spawnSync(process.execPath, [script, '--bad-dates'], { encoding: 'utf8', env }); + assert.equal(bad.status, 0, bad.stderr); + assert.match(await readFile(capture, 'utf8'), /bad-date-fixtures\.example/); + 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 4022dd6b132e0221367caf09aa74d83233a0c57c Mon Sep 17 00:00:00 2001 From: kzoeps Date: Thu, 24 Sep 2026 14:25:23 +0600 Subject: [PATCH 3/4] locations: use isolated plc DID for bad-date fixtures --- hypercerts-api/tests/fixtures/bad-location-dates.js | 2 +- hypercerts-api/tests/fixtures/bad-location-dates.test.js | 2 +- hypercerts-api/tooling/seed.test.js | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/hypercerts-api/tests/fixtures/bad-location-dates.js b/hypercerts-api/tests/fixtures/bad-location-dates.js index 807ee1a0..6155b6cf 100644 --- a/hypercerts-api/tests/fixtures/bad-location-dates.js +++ b/hypercerts-api/tests/fixtures/bad-location-dates.js @@ -5,7 +5,7 @@ import { jsonToLex } from '@atproto/lexicon'; import { locationRecords } from './records.js'; const base = locationRecords[0]; -const did = 'did:web:bad-date-fixtures.example'; +const did = 'did:plc:baddatefixturesexamplexx'; const collection = base.collection; const prefix = `at://${did}/${collection}/`; diff --git a/hypercerts-api/tests/fixtures/bad-location-dates.test.js b/hypercerts-api/tests/fixtures/bad-location-dates.test.js index c3108fd5..6df90fc0 100644 --- a/hypercerts-api/tests/fixtures/bad-location-dates.test.js +++ b/hypercerts-api/tests/fixtures/bad-location-dates.test.js @@ -21,7 +21,7 @@ test('bad-date fixtures have their own author and do not match normal fixture fi const normalUris = new Set(normal.map(({ uri }) => uri)); assert.equal(new Set(badDateLocations.map(({ uri }) => uri)).size, badDateLocations.length); for (const row of badDateLocations) { - assert.equal(row.did, 'did:web:bad-date-fixtures.example'); + 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); diff --git a/hypercerts-api/tooling/seed.test.js b/hypercerts-api/tooling/seed.test.js index 27aa4e30..d0ca4c25 100644 --- a/hypercerts-api/tooling/seed.test.js +++ b/hypercerts-api/tooling/seed.test.js @@ -149,7 +149,7 @@ test('bad-date seed input uses eight parameters only for the fourteen isolated f assert.match(sql, /PREPARE happyview_location_fixture \(text, text, text, text, jsonb, text, text, text\)/); assert.match(sql, /\$7, \$8\) ON CONFLICT/); assert.equal((sql.match(/^EXECUTE happyview_location_fixture\(/gm) ?? []).length, 14); - assert.match(sql, /bad-date-fixtures\.example/); + 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]; @@ -162,7 +162,7 @@ test('bad-date seed input uses eight parameters only for the fourteen isolated f } const normal = buildSeedInput(safeSeedEnv); assert.match(normal, /PREPARE happyview_location_fixture \(text, text, text, text, jsonb, text, text\)/); - assert.doesNotMatch(normal, /bad-date-fixtures\.example/); + 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 () => { @@ -172,10 +172,10 @@ test('bad-date CLI is opt-in, passes eight-parameter SQL to selected executable, 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); - assert.doesNotMatch(await readFile(capture, 'utf8'), /bad-date-fixtures\.example/); + assert.doesNotMatch(await readFile(capture, 'utf8'), /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'), /bad-date-fixtures\.example/); + 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 }); From a405f9ad62189a98344996c31d3a9bc07e380e32 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Thu, 24 Sep 2026 14:29:46 +0600 Subject: [PATCH 4/4] locations: compute fixture CIDs from stored records with atcute --- hypercerts-api/README.md | 2 + hypercerts-api/package.json | 7 +-- hypercerts-api/pnpm-lock.yaml | 46 ++++++++++++++----- .../tests/fixtures/bad-location-dates.js | 6 +-- .../tests/fixtures/fixtures.test.js | 26 +++-------- hypercerts-api/tests/fixtures/records.js | 6 +-- 6 files changed, 54 insertions(+), 39 deletions(-) diff --git a/hypercerts-api/README.md b/hypercerts-api/README.md index cc293972..82cf37e1 100644 --- a/hypercerts-api/README.md +++ b/hypercerts-api/README.md @@ -2,4 +2,6 @@ This package provides the conflict-safe HappyView admin installer, pinned Lexicon dependencies, deterministic location fixtures, and guarded seeding tools used by the location API. 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 installer reads existing assets first, refuses unexpected differences, and writes missing assets in dependency order. Installing assets requires the complete location package and an explicitly approved HappyView target. Seeding bypasses ingestion and is only for a separately approved, disposable loopback PostgreSQL test database; never use these fixtures on persistent data. See the dependent PR for the full install and contract-test procedure. diff --git a/hypercerts-api/package.json b/hypercerts-api/package.json index 4a0adf0b..5d971fa7 100644 --- a/hypercerts-api/package.json +++ b/hypercerts-api/package.json @@ -3,10 +3,11 @@ "private": true, "type": "module", "dependencies": { - "@hypercerts-org/lexicon": "1.4.0", - "@atproto/lex-cbor": "0.0.16", + "@atcute/cbor": "2.3.2", + "@atcute/cid": "2.4.1", "@atproto/lexicon": "0.7.4", - "@atproto/syntax": "0.6.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", diff --git a/hypercerts-api/pnpm-lock.yaml b/hypercerts-api/pnpm-lock.yaml index 16e66805..79a4aaaa 100644 --- a/hypercerts-api/pnpm-lock.yaml +++ b/hypercerts-api/pnpm-lock.yaml @@ -8,9 +8,12 @@ importers: .: dependencies: - '@atproto/lex-cbor': - specifier: 0.0.16 - version: 0.0.16 + '@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 @@ -23,6 +26,18 @@ importers: 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==} @@ -30,9 +45,6 @@ packages: resolution: {integrity: sha512-FumNnUOUsk68gP+jU0sXY2wY2piqFV9/qU2YaoQq/IZknhJBlLYCzjQXXCN0FCzxAZWD0YUzgydeyuRvFoAxfA==} engines: {node: '>=22'} - '@atproto/lex-cbor@0.0.16': - resolution: {integrity: sha512-x3NTvOX5/4Wh7uk8RNJpUJqZjcWRSUYDYRJ6VZPLmp/CAnsMySmBAinBu/dva/1hqS3C1oiYz258x6bRYpKJ4w==} - '@atproto/lex-data@0.0.15': resolution: {integrity: sha512-ZsbGiaM5S3CnGrcTMbDGON3bLZzCi/Mx9UvcMREKSRujnF68eHgMiXxJqvykP7+QpOX6tYCK93axZkuJVhtSEw==} @@ -101,6 +113,23 @@ packages: 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 @@ -115,11 +144,6 @@ snapshots: '@atproto/syntax': 0.7.6 zod: 3.25.76 - '@atproto/lex-cbor@0.0.16': - dependencies: - '@atproto/lex-data': 0.0.15 - tslib: 2.8.1 - '@atproto/lex-data@0.0.15': dependencies: multiformats: 9.9.0 diff --git a/hypercerts-api/tests/fixtures/bad-location-dates.js b/hypercerts-api/tests/fixtures/bad-location-dates.js index 6155b6cf..325f4e47 100644 --- a/hypercerts-api/tests/fixtures/bad-location-dates.js +++ b/hypercerts-api/tests/fixtures/bad-location-dates.js @@ -1,7 +1,7 @@ // Deliberately invalid record dates: use only with an explicitly disposable PostgreSQL test target. // These bypass normal Lexicon validation and must never be added to records.js or routine seeding. -import { cidForLex } from '@atproto/lex-cbor'; -import { jsonToLex } from '@atproto/lexicon'; +import { encode } from '@atcute/cbor'; +import * as CID from '@atcute/cid'; import { locationRecords } from './records.js'; const base = locationRecords[0]; @@ -33,7 +33,7 @@ export const badDateLocations = await Promise.all(cases.map(async ([rkey, create return { uri: prefix + rkey, did, collection, rkey, record, indexedAt, storedAt: '2025-01-02T03:04:05.123456Z', - cid: (await cidForLex(jsonToLex(record))).toString(), + cid: CID.toString(await CID.create(0x71, encode(record))), }; })); diff --git a/hypercerts-api/tests/fixtures/fixtures.test.js b/hypercerts-api/tests/fixtures/fixtures.test.js index 61e3e853..fb844cc4 100644 --- a/hypercerts-api/tests/fixtures/fixtures.test.js +++ b/hypercerts-api/tests/fixtures/fixtures.test.js @@ -1,31 +1,18 @@ 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'; -function decodeCid(cid) { - const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; - let bits = 0; - let value = 0; - const bytes = []; - for (const character of cid.slice(1)) { - value = (value << 5) | alphabet.indexOf(character); - bits += 5; - if (bits >= 8) { - bytes.push((value >>> (bits - 8)) & 255); - bits -= 8; - } - } - return Buffer.from(bytes); -} - 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 cidBytes = decodeCid(row.cid); - assert.equal(cidBytes.length, 36); - assert.deepEqual([...cidBytes.subarray(0, 4)], [1, 0x71, 0x12, 0x20]); + 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'); } }); @@ -33,6 +20,7 @@ test('fixtures have consistent full AT-URIs, valid DID/TID/CID identifiers, type 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' }, diff --git a/hypercerts-api/tests/fixtures/records.js b/hypercerts-api/tests/fixtures/records.js index 31ad150d..8f1a32ae 100644 --- a/hypercerts-api/tests/fixtures/records.js +++ b/hypercerts-api/tests/fixtures/records.js @@ -1,5 +1,5 @@ -import { cidForLex } from '@atproto/lex-cbor'; -import { jsonToLex } from '@atproto/lexicon'; +import { encode } from '@atcute/cbor'; +import * as CID from '@atcute/cid'; const collections = { location: 'app.certified.location', @@ -12,7 +12,7 @@ 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 = (await cidForLex(jsonToLex(record))).toString(); + const cid = CID.toString(await CID.create(0x71, encode(record))); return { uri, did: recordDid, collection, rkey, cid, indexedAt, record }; }