Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ jobs:
- run: pnpm install --frozen-lockfile --ignore-scripts
- run: pnpm check
- run: pnpm example
- run: pnpm test:demo
- run: pnpm test:package
browsers:
name: Chromium / Firefox / WebKit
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,8 @@
- Extend finite mutation tests with insertions, deletions, adjacent swaps and 3,000 distinct multiple-substitution samples; exercise Unicode rejection in three browser engines.
- Verify an offline installation of the packed archive, including public exports, TypeScript brands, documentation links and measured runtime hashes.
- Include the README graphic and supporting documents in the archive, and default future npm publications to the `alpha` tag.
- Clean previous build output before compiling, so deleted modules cannot leak into the archive; verify this with a synthetic stale module during package checks.
- Return HTTP 400 for malformed demo URLs without stopping the local server; test the asset allowlist and subsequent requests.
- Check all 912 supported prefix/payload length combinations and 1,766 count/budget pairs around exact floating-point cutoffs, including unattainable budgets.

This version number identifies the source/package candidate. Publication to npm or a GitHub release is not implied.
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Start with a concrete reproduction: what reference was entered, expected behavior, actual behavior, runtime and version. Use synthetic public references; do not post private records or access tokens.

Run `pnpm install --frozen-lockfile --ignore-scripts` and `pnpm check`. Run `pnpm test:package` to verify the installed archive's API, types and documents. Changes to parsing or the form also need `pnpm test:browser` after installing Playwright's browser engines. Keep the format specification and examples consistent. Add a regression that demonstrates a real failure, and retain the standard vectors and independent oracle.
Run `pnpm install --frozen-lockfile --ignore-scripts` and `pnpm check`. Run `pnpm test:package` to verify the installed archive's API, types and documents. Builds replace `dist/`; do not keep hand-written files there. Changes to the demo server need `pnpm test:demo` (uses an ephemeral loopback port). Changes to parsing or the form also need `pnpm test:browser` after installing Playwright's browser engines. Keep the format specification and examples consistent. Add a regression that demonstrates a real failure, and retain the standard vectors and independent oracle.

Use `refmint/<task>` for branches. Keep the runtime dependency-free and the API focused. New formats, frameworks or language ports need a demonstrated consumer need. Evidence must describe the environment and measurement method; no unsupported performance or adoption claims.

Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ RefMint has six checksum characters of overhead and does not sort by time. It ma
```sh
pnpm check # build, standard vectors, independent oracle, mutation + type tests
pnpm example # actual lookup example
pnpm test:demo # malformed HTTP requests and the demo asset allowlist
pnpm evidence # regenerate measured evidence and the SVG above
pnpm test:package # offline install of the .tgz, API + types + documentation checks
pnpm exec playwright install chromium firefox webkit
Expand All @@ -103,7 +104,9 @@ pnpm pack --pack-destination artifacts

CI runs the core checks on Node 22, 24 and 26, including Windows and macOS, plus the browser example on Chromium, Firefox and WebKit. The workflow badge reports its current result; the matrix is a test target until that run succeeds. See [SPEC.md](SPEC.md), [CHANGELOG.md](CHANGELOG.md), [security policy](SECURITY.md) and [contribution guide](CONTRIBUTING.md).

The package check installs into a separate consumer with an empty dependency store and network access disabled for installation. It checks the exported API, namespace types, shipped documentation links and runtime hashes against the recorded evidence. Future npm alpha publications default to the `alpha` tag; that setting does not publish a package.
The package check first plants an obsolete build file and verifies that a clean build removes it. It then installs into a separate consumer with an empty dependency store and network access disabled for installation, checking the archive contents, exported API, namespace types, shipped documentation links and runtime hashes against the recorded evidence. Future npm alpha publications default to the `alpha` tag; that setting does not publish a package.

Boundary tests cover all **912** supported prefix/payload length pairs and **1,766** count/budget pairs near floating-point cutoffs. The budget oracle compares exact integer ratios, including subnormal targets and impossible budgets. These checks validate the stated rules; they do not measure human error rates.

## License

Expand Down
6 changes: 5 additions & 1 deletion docs/evidence.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ Run `pnpm check && pnpm evidence` from a clean checkout after installing the loc

Core tests check all 7 valid and 14 invalid generic Bech32m vectors from BIP-350. They compare generated output with a separate implementation (`bech32` 2.0.0) for all payload lengths from 8 through 64 and four prefixes. The planner is checked against exact BigInt pair counts at representative budgets. Type checks demonstrate namespace separation.

The boundary suite extends public-API round trips to all 16 prefix lengths × 57 payload lengths = **912 combinations**, including prefixes containing `1`, display normalization, and the raw-input limits of 128/129 characters. Its independent numeric oracle converts each budget's IEEE-754 value into an exact integer ratio. It checks **1,766 distinct count/budget pairs**, including immediately neighboring floating-point values around length transitions, counts up to the largest safe integer, subnormal targets and unattainable budgets. Selected non-string inputs, including an object with throwing access hooks, must be rejected without inspecting or coercing them.

The evidence script enumerates 31 alternate symbols at every payload and checksum position for four known references. Their payload lengths are 8, 14, 26 and 64, giving `(14 + 20 + 32 + 70) × 31 = 4,216` substitutions. All must be rejected or the script fails. The core suite additionally checks 3,000 distinct deterministic samples with 2–4 substituted positions (1,000 per count), all single-symbol insertions and deletions in those four bodies, and adjacent swaps of unequal symbols. This is finite test evidence; it is not an exhaustive proof of the standard's error-detection properties. ASCII case folding and valid grouping are deliberately accepted and are not counted as detected errors. Non-ASCII input is rejected before case folding.

Browser tests exercise the actual form in Chromium, Firefox and WebKit: printed uppercase reference, one typo, unknown valid reference, replacing the sample ticket, a narrow viewport and rejection of a Unicode lookalike. There are three tests per engine. Hosted results are available in the CI workflow. Local results and hosted results must be identified separately.

`pnpm test:package` packs the distributable and installs it offline into a separate consumer with an empty store. It checks runtime imports, TypeScript namespace brands, blocked private-module imports, shipped Markdown links and SHA-256 hashes of the measured JavaScript. The package includes the evidence image and documents; builds force LF line endings so the same source produces the same runtime hashes on the tested operating systems.
`pnpm test:package` seeds a synthetic obsolete module in `dist/`, verifies that the pack build removes it, and checks the archive's exact build-file inventory. It installs the archive offline into a separate consumer with an empty store and checks runtime imports, TypeScript namespace brands, blocked private-module imports, shipped Markdown links and SHA-256 hashes of the measured JavaScript. The package includes the evidence image and documents; builds force LF line endings so the same source produces the same runtime hashes on the tested operating systems.

`pnpm test:demo` uses an ephemeral loopback port. Malformed request targets must return HTTP 400 and leave the next normal request working; only the three demo assets are served, with the expected MIME types and no caching. These tests cover the local development server, which is not a production hosting service.

## Size and speed

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,11 @@
"THIRD_PARTY_NOTICES.md"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"build": "node scripts/build.mjs",
"test": "node --test tests/*.test.mjs",
"test:types": "tsc -p tests/tsconfig.json",
"test:package": "node scripts/check-package.mjs",
"test:demo": "node --test tests/demo/*.test.mjs",
"check": "pnpm build && pnpm test && pnpm test:types",
"example": "node examples/ticket-lookup.mjs",
"evidence": "node scripts/evidence.mjs",
Expand Down
12 changes: 12 additions & 0 deletions scripts/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { rm } from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';

const root = new URL('..', import.meta.url);
// TypeScript does not remove outputs of deleted or renamed source files.
await rm(new URL('dist/', root), { recursive: true, force: true });
const result = spawnSync(process.execPath, [
fileURLToPath(new URL('node_modules/typescript/bin/tsc', root)), '-p', 'tsconfig.json'
], { cwd: fileURLToPath(root), stdio: 'inherit' });
if (result.error) throw result.error;
process.exitCode = result.status ?? 1;
9 changes: 9 additions & 0 deletions scripts/check-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const root = fileURLToPath(new URL('..', import.meta.url));
const manager = process.env.npm_execpath;
assert.ok(manager, 'Run this check with pnpm test:package');
const temp = await mkdtemp(join(tmpdir(), 'refmint-package-'));
let stale;
function run(script, args, cwd) {
const result = spawnSync(process.execPath, [script, ...args], { cwd, encoding: 'utf8', env: process.env, timeout: 120000 });
if (result.status !== 0) throw new Error(`${script} failed:\n${result.stdout}\n${result.stderr}\n${result.error ?? ''}`);
Expand All @@ -19,7 +20,12 @@ try {
const source = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
const packs = join(temp, 'packs');
await mkdir(packs);
// A previous build may have emitted a module that has since been deleted.
await mkdir(join(root, 'dist'), { recursive: true });
stale = await mkdtemp(join(root, 'dist', 'refmint-stale-'));
await writeFile(join(stale, 'removed-module.js'), '// Synthetic old build output.\n');
run(manager, ['pack', '--pack-destination', packs], root);
await assert.rejects(readFile(join(stale, 'removed-module.js')), { code: 'ENOENT' });
const archives = (await readdir(packs)).filter(file => file.endsWith('.tgz'));
assert.equal(archives.length, 1);
const consumer = join(temp, 'consumer');
Expand All @@ -36,6 +42,8 @@ try {
assert.equal(packaged.publishConfig.tag, 'alpha');
assert.deepEqual(packaged.dependencies ?? {}, {});
const files = await readdir(installed, { recursive: true });
assert.deepEqual((await readdir(join(installed, 'dist'))).sort(),
['bech32m.d.ts', 'bech32m.js', 'index.d.ts', 'index.js'], 'Unexpected build output in the archive');
assert.ok(!files.some(file => /(^|[/\\])(src|tests|scripts|\.github|node_modules)([/\\]|$)/.test(file)));
for (const name of ['dist/index.js', 'dist/index.d.ts', 'dist/bech32m.js', 'SPEC.md', 'LICENSE-MIT', 'LICENSE-APACHE', 'THIRD_PARTY_NOTICES.md']) {
assert.ok((await readFile(join(installed, name))).length > 0, name);
Expand Down Expand Up @@ -83,5 +91,6 @@ if (result.ok) docs.format(result.id);
run(join(root, 'node_modules/typescript/bin/tsc'), ['--noEmit', '--strict', '--target', 'ES2022', '--module', 'NodeNext', '--moduleResolution', 'NodeNext', 'types.mts'], consumer);
console.log(`PASS: ${source.name}@${source.version} archive installs offline; ESM, types, documentation and licenses are intact.`);
} finally {
if (stale) await rm(stale, { recursive: true, force: true });
await rm(temp, { recursive: true, force: true });
}
23 changes: 23 additions & 0 deletions scripts/demo-server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';

const files = new Map([
['/', ['../examples/browser/index.html', 'text/html; charset=utf-8']],
['/dist/index.js', ['../dist/index.js', 'text/javascript; charset=utf-8']],
['/dist/bech32m.js', ['../dist/bech32m.js', 'text/javascript; charset=utf-8']]
]);

export function createDemoServer() {
return createServer(async (req, res) => {
let pathname;
try { pathname = new URL(req.url, 'http://localhost').pathname; }
catch { res.writeHead(400).end('Bad request.'); return; }
const entry = files.get(pathname);
if (!entry || req.method !== 'GET') { res.writeHead(404).end(); return; }
try {
const data = await readFile(new URL(entry[0], import.meta.url));
res.writeHead(200, { 'Content-Type': entry[1], 'Cache-Control': 'no-store' });
res.end(data);
} catch { res.writeHead(500).end('Build the library with pnpm build first.'); }
});
}
18 changes: 2 additions & 16 deletions scripts/serve.mjs
Original file line number Diff line number Diff line change
@@ -1,18 +1,4 @@
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { createDemoServer } from './demo-server.mjs';

const files = new Map([
['/', ['../examples/browser/index.html', 'text/html; charset=utf-8']],
['/dist/index.js', ['../dist/index.js', 'text/javascript; charset=utf-8']],
['/dist/bech32m.js', ['../dist/bech32m.js', 'text/javascript; charset=utf-8']]
]);
const server = createServer(async (req, res) => {
const entry = files.get(new URL(req.url, 'http://localhost').pathname);
if (!entry || req.method !== 'GET') { res.writeHead(404).end(); return; }
try {
const data = await readFile(new URL(entry[0], import.meta.url));
res.writeHead(200, { 'Content-Type': entry[1], 'Cache-Control': 'no-store' });
res.end(data);
} catch { res.writeHead(500).end('Build the library with pnpm build first.'); }
});
const server = createDemoServer();
server.listen(4173, '127.0.0.1', () => console.log('RefMint demo: http://127.0.0.1:4173'));
91 changes: 91 additions & 0 deletions tests/boundaries.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { bech32m } from 'bech32';
import { createReferences, planReferences } from '../dist/index.js';

// Independent oracle: compare exact integer ratios, including the precise IEEE
// value of the user's budget, rather than rounding a bound as the library does.
function ratio(value) {
const view = new DataView(new ArrayBuffer(8));
view.setFloat64(0, value);
const bits = view.getBigUint64(0);
const exponent = Number((bits >> 52n) & 0x7ffn);
const significand = (bits & ((1n << 52n) - 1n)) | (exponent ? 1n << 52n : 0n);
const shift = exponent ? exponent - 1075 : -1074;
return shift >= 0 ? [significand << BigInt(shift), 1n] : [significand, 1n << BigInt(-shift)];
}

function neighbor(value, direction) {
if (value === 0 && direction < 0) return 0;
const view = new DataView(new ArrayBuffer(8));
view.setFloat64(0, value);
view.setBigUint64(0, view.getBigUint64(0) + BigInt(direction));
return view.getFloat64(0);
}

test('planner meets exact budgets immediately above and below length cutoffs', t => {
let checked = 0;
const spaces = Array.from({ length: 65 }, (_, length) => 1n << BigInt(5 * length));
const counts = [1, 2, 3, 1000, 1_000_000, 2 ** 26, 2 ** 27 + 1, 2 ** 32 - 1, 2 ** 32, 2 ** 48 + 1,
Number.MAX_SAFE_INTEGER - 1, Number.MAX_SAFE_INTEGER];
for (const count of counts) {
const pairs = BigInt(count) * BigInt(count - 1) / 2n;
const budgets = new Set([Number.MIN_VALUE, 2 ** -320, 1e-9, neighbor(1, -1)]);
for (let length = 8; length <= 64; length++) {
const cutoff = Number(pairs) / 32 ** length;
for (const p of [neighbor(cutoff, -1), cutoff, neighbor(cutoff, 1)]) {
if (p > 0 && p < 1) budgets.add(p);
}
}
for (const p of budgets) {
const [numerator, denominator] = ratio(p);
const exactLength = spaces.findIndex((space, length) => length >= 8 && pairs * denominator <= numerator * space);
const options = { prefix: 'budget', expectedCount: count, maxCollisionProbability: p };
if (exactLength === -1) assert.throws(() => planReferences(options), RangeError);
else {
const plan = planReferences(options);
assert.equal(plan.randomLength, exactLength, `${count} references at budget ${p}`);
assert.ok(plan.collisionUpperBound <= p);
const [boundNumerator, boundDenominator] = ratio(plan.collisionUpperBound);
assert.ok(boundNumerator * spaces[exactLength] >= pairs * boundDenominator);
assert.ok(plan.collisionProbability >= 0 && plan.collisionProbability <= plan.collisionUpperBound);
}
checked++;
}
}
t.diagnostic(`${checked} distinct count/budget pairs checked with exact integer ratios`);
});

test('all 912 prefix/payload length combinations preserve canonical identity', () => {
for (let prefixLength = 1; prefixLength <= 16; prefixLength++) {
const prefix = ('a123456789abcdef').repeat(2).slice(0, prefixLength);
for (let length = 8; length <= 64; length++) {
const refs = createReferences({ prefix, randomLength: length });
const id = bech32m.encode(prefix, Array.from({ length }, (_, i) => (i * 17 + length) % 32));
assert.equal(id.length, refs.plan.canonicalLength);
assert.deepEqual(refs.parseCanonical(id), { ok: true, id, normalized: false });
const printed = refs.format(id);
for (const input of [id.toUpperCase(), printed, printed.toUpperCase(), ' \t' + printed + '\r\n']) {
assert.deepEqual(refs.parse(input), { ok: true, id, normalized: true });
assert.equal(refs.parseCanonical(input).error, 'non_canonical');
}
// Check the actual input limit independently of the canonical length.
const padded = ' '.repeat(128 - printed.length) + printed;
assert.equal(refs.parse(padded).id, id);
assert.equal(refs.parse(padded + ' ').error, 'too_long');
const generated = refs.generate();
const decoded = bech32m.decode(generated);
assert.equal(decoded.prefix, prefix);
assert.equal(decoded.words.length, length);
}
}
});

test('parsing arbitrary values does not invoke user conversion hooks', () => {
const refs = createReferences({ prefix: 'ticket' });
const hostile = new Proxy({}, { get() { throw new Error('Do not inspect this object'); } });
for (const input of [undefined, null, false, 0, NaN, Infinity, 1n, Symbol('input'), [], {}, new String('ticket'), hostile]) {
assert.deepEqual(refs.parse(input), { ok: false, error: 'type' });
assert.deepEqual(refs.parseCanonical(input), { ok: false, error: 'type' });
}
});
Loading
Loading