diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdd0883..8d8eb32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,19 +33,27 @@ jobs: - run: pnpm check - run: pnpm example - run: pnpm test:demo + - run: pnpm test:comparison - run: pnpm test:package browsers: name: Chromium / Firefox / WebKit runs-on: ubuntu-latest timeout-minutes: 20 + # Match @playwright/test; browsers and OS dependencies are already installed. + # Avoid downloading apt packages from the runner's mirror on every run. + container: + image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea + options: --user 1001 --init --ipc=host steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: 24 package-manager-cache: false - - run: npm install -g pnpm@11.25.0 + - name: Install pnpm for the container user + run: | + npm install -g --prefix "$RUNNER_TEMP/refmint-pnpm" pnpm@11.25.0 + echo "$RUNNER_TEMP/refmint-pnpm/bin" >> "$GITHUB_PATH" - run: pnpm install --frozen-lockfile --ignore-scripts - run: pnpm build - - run: pnpm exec playwright install --with-deps chromium firefox webkit - run: pnpm test:browser diff --git a/CHANGELOG.md b/CHANGELOG.md index 2666d98..ba1dec5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.1.0-alpha.2 — 2026-10-27 + +Second GitHub prerelease, including the existing-ID API, compatibility checks, comparison form and measured optimizations. Distributed as a package archive; not published to the npm registry. + +- Optimize checksum evaluation and parsing without changing formats or diagnostics; use exact 50-bit chunks for bigint decoding. Add an optional `refmint/codec` import and strip implementation comments from shipped JS while preserving source/type documentation. Record full-root and codec-only sizes, a same-process comparison against the previous build, and differential behavior checks. +- Encode existing uint64 and UUID values with an explicit v1 contract, without allocating a second random ID. Preserve integer precision through bigint and reject overflow/noncanonical encodings. +- Keep alpha.1 random references unchanged and structurally separate from the new format; add frozen compatibility vectors and namespace/kind TypeScript brands. +- Add opt-in `diagnose` results with raw-input invalid-character positions and explicit length/namespace details. Existing parse result shapes remain unchanged; checksum errors never autocorrect a code. +- Add a return-lookup form and an independent Bech32m adapter with identical input rules, records and diagnostics. Record shared scenarios, mutation fixtures, adapter/loaded-code sizes, reference lengths and local timings with explicit evidence limits. + +[Release notes and downloads](https://github.com/PerchAnton/refmint/releases/tag/v0.1.0-alpha.2). + ## 0.1.0-alpha.1 — 2026-10-27 First GitHub prerelease, distributed as a package archive. The npm registry publication is separate. The API and reference format are experimental. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c88967d..fb8c25b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,3 +7,7 @@ Run `pnpm install --frozen-lockfile --ignore-scripts` and `pnpm check`. Run `pnp Use `refmint/` 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. After runtime changes, run `pnpm evidence` before `pnpm test:package`: the package check compares the installed JavaScript with the measured hashes. Builds use LF line endings on every platform. The evidence command regenerates recorded measurements and the README graphic and labels the new environment. Do not replace another machine's measurements without that label. Contributions to original code are under MIT OR Apache-2.0; preserve third-party notices. + +For the existing-ID format or comparison, run `pnpm compare` after the build and `pnpm test:comparison` to verify recorded source hashes and deterministic facts. Do not change frozen v1 vectors to accommodate an incompatible layout. The independent Bech32m adapter must not import RefMint helpers; keep both forms' inputs, validation contract and shared UI equivalent. Report library size and reference-length costs alongside any reduction in adapter code. + +The build keeps JSDoc in declarations and removes comments only from emitted JS. Keep the full-root and codec-only size figures separate; include compression costs. After runtime changes, refresh the historical comparison with `pnpm compare:previous PATH_TO_PREVIOUS_DIST FULL_COMMIT_SHA` as described in [comparison methods](docs/comparison.md#before-and-after-optimization). The recorded previous baseline is `daaffe813fd0351752bd158314917267acddc1b6`. Do not compare historical timings collected on different machines as a before/after speedup. diff --git a/README.md b/README.md index 6e16f15..e935f9f 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,13 @@ [![CI](https://github.com/PerchAnton/refmint/actions/workflows/ci.yml/badge.svg)](https://github.com/PerchAnton/refmint/actions/workflows/ci.yml) ![Status: alpha](https://img.shields.io/badge/status-alpha-d9822b) ![Runtime dependencies: 0](https://img.shields.io/badge/runtime_dependencies-0-087d5d) -[![GitHub prerelease](https://img.shields.io/badge/release-0.1.0--alpha.1-087d5d)](https://github.com/PerchAnton/refmint/releases/tag/v0.1.0-alpha.1) +[![GitHub prerelease](https://img.shields.io/badge/release-0.1.0--alpha.2-087d5d)](https://github.com/PerchAnton/refmint/releases/tag/v0.1.0-alpha.2) -RefMint is a small TypeScript library for public ticket, document and order references. It combines a random payload, a type prefix and a standard Bech32m checksum. A form can distinguish a mistyped code from a valid reference for a different record type, before querying a database. +RefMint is a small TypeScript library for public ticket, document and order references. It can generate random references or encode existing uint64/UUID values with a namespace and a standard Bech32m checksum. A form can distinguish a mistyped code from a valid reference for a different record type, before querying a database. -**Alpha:** the format and API may change. [Download the first GitHub prerelease](https://github.com/PerchAnton/refmint/releases/tag/v0.1.0-alpha.1) or build from source. The package uses ES modules and includes TypeScript declarations. Node.js 22+ and browsers with Web Crypto are the targets. This release is distributed through GitHub; it is not published to the npm registry. +**New in alpha.2:** preserve an existing uint64 or UUID with `createReferenceCodec`, inspect invalid input with `diagnose`, and load only the codec with `refmint/codec`. The release includes the optimized implementation and comparison evidence; the tagged source includes the complete comparison form. [Format v1](docs/format-v1.md) · [Comparison and measured trade-offs](docs/comparison.md). + +**Alpha:** APIs may evolve. The existing-ID v1 wire contract is frozen; incompatible formats require a new version. [Download the current GitHub prerelease](https://github.com/PerchAnton/refmint/releases/tag/v0.1.0-alpha.2) or build from source. The package uses ES modules and includes TypeScript declarations. Node.js 22+ and browsers with Web Crypto are the targets. This release is distributed through GitHub; it is not published to the npm registry. ![Measured checks and the reference lifecycle](docs/assets/evidence.svg) @@ -20,15 +22,15 @@ The image is generated from [recorded evidence](docs/evidence.json). See [measur Install the versioned package archive into your application: ```sh -npm install --allow-remote=root https://github.com/PerchAnton/refmint/releases/download/v0.1.0-alpha.1/refmint-0.1.0-alpha.1.tgz +npm install --allow-remote=root https://github.com/PerchAnton/refmint/releases/download/v0.1.0-alpha.2/refmint-0.1.0-alpha.2.tgz ``` -npm 12 requires explicit permission for URL dependencies. Pass `--allow-remote=root` on later `npm install` / `npm ci` runs for a project that keeps this URL dependency, too; the flag does not change global settings. Alternatively, download the `.tgz` asset and run `npm install ./refmint-0.1.0-alpha.1.tgz`. [npm's URL dependency setting](https://docs.npmjs.com/cli/v12/commands/npm-install/#allow-remote). +npm 12 requires explicit permission for URL dependencies. Pass `--allow-remote=root` on later `npm install` / `npm ci` runs for a project that keeps this URL dependency, too; the flag does not change global settings. Alternatively, download the `.tgz` asset and run `npm install ./refmint-0.1.0-alpha.2.tgz`. [npm's URL dependency setting](https://docs.npmjs.com/cli/v12/commands/npm-install/#allow-remote). -The release also includes `SHA256SUMS.txt` and the measured evidence. To run the local browser demo from the tagged source: +The release also includes `SHA256SUMS.txt`, verification metadata and all three measured-evidence reports. To run the local browser demo from the tagged source: ```sh -git clone --branch v0.1.0-alpha.1 https://github.com/PerchAnton/refmint.git +git clone --branch v0.1.0-alpha.2 https://github.com/PerchAnton/refmint.git cd refmint # Install pnpm 11.25.0 if it is not available. npm install -g pnpm@11.25.0 @@ -86,6 +88,35 @@ If no budget is supplied, RefMint uses **26 random characters / 130 bits**. Choo TypeScript also distinguishes `Reference<'ticket'>` from `Reference<'doc'>`. A plain string must pass the parser before it can be used as a branded reference. The brand is a compile-time aid, not an authorization boundary. +## Keep an existing ID + +Available in alpha.2. It does not allocate another random ID or require an alias column: + +```ts +import { createReferenceCodec } from 'refmint/codec' + +const returns = createReferenceCodec({ prefix: 'rma', kind: 'uint64', version: 1 }) +const printed = returns.format(returns.encode(123456n)) +const result = returns.diagnose(printed.toUpperCase()) +if (result.ok) { + console.log(result.value) // 123456n — query the original key, with authorization +} else { + console.log(result.error, result.details) // positions / expected / actual, when known +} +``` + +For UUIDs, use `kind: 'uuid'` with canonical lowercase UUID strings. A numeric codec requires `bigint` and preserves all 64 bits; it does not preserve leading zeros in a text identifier. Encoding is reversible and does not hide sequential numbers. [Full contract, diagnostics and compatibility](docs/format-v1.md). + +The `refmint/codec` entry keeps the random generator and collision planner out of existing-ID-only forms. The same codec remains exported from `refmint`; both paths share its implementation and TypeScript brands. Both entry points are included in the alpha.2 archive; alpha.1 retains only the original random-reference API. + +`createReferences(...).diagnose(input)` adds the same diagnostics for existing random references. Its `parse`/`parseCanonical` results and accepted formats remain unchanged. The new v1 grammar includes an underscore and version/kind symbols, so neither API mistakes the other format for one of its IDs. + +Run `pnpm compare && pnpm demo`, then open **http://127.0.0.1:4173/comparison/**. Both forms use the same checksum and accept the same cases. The comparison records adapter code, total loaded JavaScript, reference lengths and local timings—including costs that favor the plain Bech32m solution. [Methods, source and limitations](docs/comparison.md). The extra checking does not make codes shorter; no human-entry advantage is established. + +After optimization, the form loads **8,773 bytes** of RefMint JS plus adapter, versus **9,327 bytes** for Bech32m plus adapter. Summed per-file gzip still favors Bech32m: **3,353 vs 3,160 bytes**. The full RefMint root import is **14,147 bytes**, down from 15,255. These are unminified module totals, not bundle or install-archive sizes; RefMint's build now removes JS implementation comments while keeping source and declaration documentation. + +A same-process Node 24 / Apple M3 run of the same 28 scenarios measured **1.78 million checks/s** after optimization, **0.56 million** before, and **0.82 million** for Bech32m plus adapter. This is a local diagnostic workload, not a general speed guarantee. All **123,146 differential comparisons** against the previous implementation matched. [Raw samples, source hashes and reproduction](docs/comparison.md#before-and-after-optimization). + ## Scope and trade-offs Use RefMint when **people enter a public reference** and your application needs consistent generation, display and validation rules. Keep a **UNIQUE database constraint**, handle collisions with bounded retries, and authorize every lookup. The checksum is public and can be recomputed by anyone: it is not a signature, password, invitation secret or proof of authenticity. @@ -97,7 +128,7 @@ Use RefMint when **people enter a public reference** and your application needs | A human-oriented alphabet and check digit across languages | [HardGuard25](https://github.com/snapsynapse/hardguard25) | | A public-reference workflow with a declared collision budget and strict storage parsing | RefMint | -RefMint has six checksum characters of overhead and does not sort by time. It makes no speed advantage, adoption, reduced support cost or human-error-rate claim. Readable formatting and checksums already exist in other projects; this package's contribution is the focused API and tested workflow. Runtime code is independent of Nano ID. The checksum follows [BIP-173](https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki) / [BIP-350](https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki), with attribution in [third-party notices](THIRD_PARTY_NOTICES.md). +RefMint has six checksum characters of overhead and does not sort by time. It makes no general speed advantage, adoption, reduced support cost or human-error-rate claim. Readable formatting and checksums already exist in other projects; this package's contribution is the focused API and tested workflow. Runtime code is independent of Nano ID. The checksum follows [BIP-173](https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki) / [BIP-350](https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki), with attribution in [third-party notices](THIRD_PARTY_NOTICES.md). ## Verify and measure @@ -106,6 +137,8 @@ pnpm check # build, standard vectors, independent oracle, mutati 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 compare # measure the existing-ID form against a Bech32m adapter +pnpm test:comparison # verify the recorded comparison facts and source hashes pnpm test:package # offline install of the .tgz, API + types + documentation checks pnpm exec playwright install chromium firefox webkit pnpm test:browser # actual form flow in three browser engines diff --git a/SPEC.md b/SPEC.md index 6e3c608..8c639b4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,6 +1,6 @@ -# RefMint alpha.1 format and API +# RefMint formats and API -This is a narrow application of generic Bech32m, not a new checksum algorithm or a Bitcoin address implementation. Freeze a deployed namespace's prefix and random length. Alpha formats may change in later versions; no transparent migration is promised. +This is a narrow application of generic Bech32m, not a new checksum algorithm or a Bitcoin address implementation. The random-reference format below preserves the alpha.1 contract. Freeze a deployed namespace's prefix and random length. The separate [existing-ID format v1](docs/format-v1.md) has an explicit version and a disjoint grammar; it never reinterprets random payloads as database IDs. ## Canonical representation @@ -12,7 +12,7 @@ This is a narrow application of generic Bech32m, not a new checksum algorithm or - Maximum canonical length: 87 ASCII characters, below Bech32m's 90-character limit. - Random bytes: `crypto.getRandomValues`, one byte per payload symbol. Mask each byte by 31. Because 256 is divisible by 32, every symbol has eight preimages; this introduces no modulo bias for uniform bytes. No timestamp, counter, machine fingerprint, user-provided seed or insecure fallback. -The public API does not encode arbitrary bytes, accept custom alphabets or expose a general-purpose Bech32m codec. The internal codec is checked against all 21 generic BIP-350 test vectors and the independent `bech32` package in development tests. +The public API does not encode arbitrary bytes, accept custom alphabets or expose a general-purpose Bech32m codec. `createReferenceCodec` supports the two precisely defined existing-ID kinds in the v1 contract. The internal checksum codec is checked against all 21 generic BIP-350 test vectors and the independent `bech32` package in development tests. ## Length planning @@ -48,10 +48,13 @@ Errors identify a validation stage, not necessarily the user's exact mistake. A ## Public exports -- `createReferences(options)` → immutable `{ prefix, plan, generate, parse, parseCanonical, format }`. +- `createReferences(options)` → immutable `{ prefix, plan, generate, parse, parseCanonical, diagnose, format }`. - `planReferences(options)` → immutable plan without generating an ID or requiring randomness. - `collisionProbability(randomLength, count)` → birthday approximation, with the same supported length/count limits. -- Types: `Reference`, `ReferenceOptions`, `ReferencePlan`, `References`, `ParseError`, `ParseResult`. +- Types: `Reference`, `ReferenceOptions`, `ReferencePlan`, `References`, `ParseError`, `ParseResult`, `ParseDiagnostic`, `ValidationDetails`. +- `createReferenceCodec(options)` and its types are defined in [format v1](docs/format-v1.md). + +`diagnose(input)` follows the same rules as `parse(input)`, returning the same successful result or `{ ok: false, error, details }`. `parse` and `parseCanonical` retain their original result shapes. Diagnostics identify invalid character positions as zero-based UTF-16 offsets in the raw input, even with whitespace/grouping; non-BMP characters use their first code-unit offset. Length details carry `expected`, `actual` and an explicit `unit`; valid wrong namespaces report their expected/actual names. Checksum errors never guess positions or provide a replacement ID. Diagnostic fields are additional evidence about the failed validation stage, not proof of the user's intended code. Options: `{ prefix }` for the 26-character default; `{ prefix, randomLength }` for a fixed length; or `{ prefix, expectedCount, maxCollisionProbability }` for planning. The two sizing modes cannot be combined. Unknown options are rejected so a misspelled budget cannot silently select a default. Different prefixes have different TypeScript brands; lengths within one prefix are enforced at runtime. diff --git a/docs/assets/evidence.svg b/docs/assets/evidence.svg index 7c54241..6e04cb9 100644 --- a/docs/assets/evidence.svg +++ b/docs/assets/evidence.svg @@ -4,7 +4,7 @@ REFMINT / ALPHA EVIDENCE Make a reference. Catch a typo. Keep one storage key. -0runtime dependencies14 + 6random + check symbols4,216mutations rejected9 KiBemitted JavaScript +0runtime dependencies14 + 6random + check symbols4,216mutations rejected13.6 KiBemitted JavaScript Generate→Format for display→Parse & check→Authorized lookup 14 random symbols: plan for 1 million codes with collision bound ≤ 10⁻⁹; default is 26. 4216 finite substitutions tested. No human-error-rate or speed advantage claimed. diff --git a/docs/comparison.json b/docs/comparison.json new file mode 100644 index 0000000..e6dfd2b --- /dev/null +++ b/docs/comparison.json @@ -0,0 +1,393 @@ +{ + "generated_at": "2026-10-27T12:51:08.100Z", + "environment": { + "node": "v24.19.0", + "platform": "darwin", + "arch": "arm64", + "cpu": "Apple M3" + }, + "baseline": "bech32@2.0.0, Bech32m variant, independently implemented application adapter", + "adapters": { + "refmint": { + "file": "examples/comparison/refmint-adapter.mjs", + "code_lines": 4, + "bytes": 170, + "sha256": "8764e831e7faaebfd2c7fb0e5029cc90d6578877464b07553d2a2d4572d8b950" + }, + "bech32": { + "file": "examples/comparison/bech32-adapter.mjs", + "code_lines": 61, + "bytes": 4096, + "sha256": "f6049af2f2bd715db101ea927960c6b1bf4aa73f4f6ffa1ea22006e6de12997e" + } + }, + "loaded_code": { + "refmint": { + "files": [ + { + "file": "dist/bech32m.js", + "bytes": 2143, + "gzip_bytes": 853, + "sha256": "8541415a0ca6dafede62789c1471afd39dcf91d034fb5aad897b8a1c1219dff2" + }, + { + "file": "dist/codec.js", + "bytes": 4593, + "gzip_bytes": 1582, + "sha256": "00fa6a5f15b53f69c421a6a36c9c37bf5125cb7390adced5073b8da8956e9ab5" + }, + { + "file": "dist/index.js", + "bytes": 5374, + "gzip_bytes": 1730, + "sha256": "d805035cce7678ab09540d73cc7c898d9976593c794b4e5013f0ac9df6bfbbb6" + }, + { + "file": "dist/presentation.js", + "bytes": 1867, + "gzip_bytes": 776, + "sha256": "c80609e88d8d10226e105599f2d57c4eeedde979a960d283b49070f765b91e25" + }, + { + "file": "examples/comparison/refmint-adapter.mjs", + "bytes": 170, + "gzip_bytes": 142, + "sha256": "8764e831e7faaebfd2c7fb0e5029cc90d6578877464b07553d2a2d4572d8b950" + } + ], + "bytes": 14147, + "summed_gzip_bytes": 5083 + }, + "refmint_codec": { + "files": [ + { + "file": "dist/bech32m.js", + "bytes": 2143, + "gzip_bytes": 853, + "sha256": "8541415a0ca6dafede62789c1471afd39dcf91d034fb5aad897b8a1c1219dff2" + }, + { + "file": "dist/codec.js", + "bytes": 4593, + "gzip_bytes": 1582, + "sha256": "00fa6a5f15b53f69c421a6a36c9c37bf5125cb7390adced5073b8da8956e9ab5" + }, + { + "file": "dist/presentation.js", + "bytes": 1867, + "gzip_bytes": 776, + "sha256": "c80609e88d8d10226e105599f2d57c4eeedde979a960d283b49070f765b91e25" + }, + { + "file": "examples/comparison/refmint-adapter.mjs", + "bytes": 170, + "gzip_bytes": 142, + "sha256": "8764e831e7faaebfd2c7fb0e5029cc90d6578877464b07553d2a2d4572d8b950" + } + ], + "bytes": 8773, + "summed_gzip_bytes": 3353 + }, + "bech32": { + "files": [ + { + "file": "node_modules/bech32/dist/index.js", + "bytes": 5231, + "gzip_bytes": 1553, + "sha256": "7e389c4495ce1fc3a0024485d1554494a959735c68caf8e677951275b15ea3cc" + }, + { + "file": "examples/comparison/bech32-adapter.mjs", + "bytes": 4096, + "gzip_bytes": 1607, + "sha256": "f6049af2f2bd715db101ea927960c6b1bf4aa73f4f6ffa1ea22006e6de12997e" + } + ], + "bytes": 9327, + "summed_gzip_bytes": 3160 + } + }, + "lengths": [ + { + "kind": "uint64", + "original": 6, + "refmint": 17, + "bech32": 17, + "printed": 19 + }, + { + "kind": "uuid", + "original": 36, + "refmint": 39, + "bech32": 39, + "printed": 47 + } + ], + "scenarios": [ + { + "kind": "uint64", + "scenario": "Canonical", + "outcome": "found", + "error": null, + "refmint_queries": 1, + "bech32_queries": 1 + }, + { + "kind": "uint64", + "scenario": "Printed uppercase", + "outcome": "found", + "error": null, + "refmint_queries": 1, + "bech32_queries": 1 + }, + { + "kind": "uint64", + "scenario": "Surrounding whitespace", + "outcome": "found", + "error": null, + "refmint_queries": 1, + "bech32_queries": 1 + }, + { + "kind": "uint64", + "scenario": "Changed symbol", + "outcome": "invalid", + "error": "checksum", + "refmint_queries": 0, + "bech32_queries": 0 + }, + { + "kind": "uint64", + "scenario": "Illegal character", + "outcome": "invalid", + "error": "character", + "refmint_queries": 0, + "bech32_queries": 0 + }, + { + "kind": "uint64", + "scenario": "Unicode lookalike", + "outcome": "invalid", + "error": "character", + "refmint_queries": 0, + "bech32_queries": 0 + }, + { + "kind": "uint64", + "scenario": "Mixed case", + "outcome": "invalid", + "error": "case", + "refmint_queries": 0, + "bech32_queries": 0 + }, + { + "kind": "uint64", + "scenario": "Broken grouping", + "outcome": "invalid", + "error": "format", + "refmint_queries": 0, + "bech32_queries": 0 + }, + { + "kind": "uint64", + "scenario": "Wrong namespace", + "outcome": "invalid", + "error": "prefix", + "refmint_queries": 0, + "bech32_queries": 0 + }, + { + "kind": "uint64", + "scenario": "Unknown version", + "outcome": "invalid", + "error": "version", + "refmint_queries": 0, + "bech32_queries": 0 + }, + { + "kind": "uint64", + "scenario": "Wrong value kind", + "outcome": "invalid", + "error": "kind", + "refmint_queries": 0, + "bech32_queries": 0 + }, + { + "kind": "uint64", + "scenario": "Missing payload", + "outcome": "invalid", + "error": "length", + "refmint_queries": 0, + "bech32_queries": 0 + }, + { + "kind": "uint64", + "scenario": "Overflow", + "outcome": "invalid", + "error": "overflow", + "refmint_queries": 0, + "bech32_queries": 0 + }, + { + "kind": "uint64", + "scenario": "Valid but unknown", + "outcome": "unknown", + "error": null, + "refmint_queries": 1, + "bech32_queries": 1 + }, + { + "kind": "uuid", + "scenario": "Canonical", + "outcome": "found", + "error": null, + "refmint_queries": 1, + "bech32_queries": 1 + }, + { + "kind": "uuid", + "scenario": "Printed uppercase", + "outcome": "found", + "error": null, + "refmint_queries": 1, + "bech32_queries": 1 + }, + { + "kind": "uuid", + "scenario": "Surrounding whitespace", + "outcome": "found", + "error": null, + "refmint_queries": 1, + "bech32_queries": 1 + }, + { + "kind": "uuid", + "scenario": "Changed symbol", + "outcome": "invalid", + "error": "checksum", + "refmint_queries": 0, + "bech32_queries": 0 + }, + { + "kind": "uuid", + "scenario": "Illegal character", + "outcome": "invalid", + "error": "character", + "refmint_queries": 0, + "bech32_queries": 0 + }, + { + "kind": "uuid", + "scenario": "Unicode lookalike", + "outcome": "invalid", + "error": "character", + "refmint_queries": 0, + "bech32_queries": 0 + }, + { + "kind": "uuid", + "scenario": "Mixed case", + "outcome": "invalid", + "error": "case", + "refmint_queries": 0, + "bech32_queries": 0 + }, + { + "kind": "uuid", + "scenario": "Broken grouping", + "outcome": "invalid", + "error": "format", + "refmint_queries": 0, + "bech32_queries": 0 + }, + { + "kind": "uuid", + "scenario": "Wrong namespace", + "outcome": "invalid", + "error": "prefix", + "refmint_queries": 0, + "bech32_queries": 0 + }, + { + "kind": "uuid", + "scenario": "Unknown version", + "outcome": "invalid", + "error": "version", + "refmint_queries": 0, + "bech32_queries": 0 + }, + { + "kind": "uuid", + "scenario": "Wrong value kind", + "outcome": "invalid", + "error": "kind", + "refmint_queries": 0, + "bech32_queries": 0 + }, + { + "kind": "uuid", + "scenario": "Missing payload", + "outcome": "invalid", + "error": "length", + "refmint_queries": 0, + "bech32_queries": 0 + }, + { + "kind": "uuid", + "scenario": "Overflow", + "outcome": "invalid", + "error": "overflow", + "refmint_queries": 0, + "bech32_queries": 0 + }, + { + "kind": "uuid", + "scenario": "Valid but unknown", + "outcome": "unknown", + "error": null, + "refmint_queries": 1, + "bech32_queries": 1 + } + ], + "mutations": { + "tested_per_adapter": 1576, + "rejected_per_adapter": 1576, + "method": "All alternative lowercase letters in rma, and all alternative alphabet symbols in data/checksum of two fixed fixtures. Underscore/separator and normalization excluded." + }, + "timing": { + "method": "Seven alternating-order rounds, 200 warmup passes, 1000 passes per round through the same 28 cases. Diagnostic parsing only; no database, rendering or network.", + "refmint": { + "ops_per_second": [ + 1557351, + 1673294, + 1745341, + 1750688, + 1751893, + 1766664, + 1786251 + ], + "median_ops_per_second": 1750688 + }, + "bech32": { + "ops_per_second": [ + 720805, + 759435, + 847729, + 849267, + 853292, + 845110, + 864805 + ], + "median_ops_per_second": 847729 + } + }, + "limits": [ + "This compares two implementations of the RefMint v1 contract, not every alternative ID scheme.", + "Adapter lines exclude blank/comment lines, shared UI, fixtures and lookup; RefMint moves contract logic into its library. Line count alone is not a maintainability measure.", + "refmint counts all emitted JS plus its adapter; refmint_codec follows the actual codec-only imports used by the form. No minification/tree shaking. RefMint JS omits implementation comments; source and declaration comments are preserved. Bech32 uses its shipped JS unchanged. Shared UI and browser-only CJS wrapper are excluded. gzip files are compressed separately and summed.", + "Both use the same standard checksum and produce the same codes. Both reject the tested mutations.", + "Lookup counts describe an in-memory example, not measured production database savings.", + "No human study, adoption evidence or general speed advantage. Timing is machine-specific and input-mix-dependent.", + "Sequential IDs remain recoverable. The checksum does not provide authentication or authorization." + ] +} diff --git a/docs/comparison.md b/docs/comparison.md new file mode 100644 index 0000000..c845993 --- /dev/null +++ b/docs/comparison.md @@ -0,0 +1,73 @@ +# RefMint and a Bech32m application adapter + +Build the alpha.2 tagged source, run `pnpm compare`, then `pnpm demo` and open **http://127.0.0.1:4173/comparison/**. The alpha.2 package archive includes the existing-ID APIs and recorded evidence; run the form and measurement scripts from the source checkout. The alpha.1 archive contains only the original random-reference API. + +The form is a return lookup using an existing numeric key or UUID. It restores that original value before looking up one in-memory record. No second alias is generated, stored or indexed. This example does not replace an application's authorization or demonstrate real database savings. + +## Fair baseline + +The baseline uses the published `bech32@2.0.0` package's Bech32m variant. That is the same checksum algorithm used by RefMint. It has an independent application adapter with the same namespace, version, value-kind, precision, canonicality, presentation and diagnostic rules. It does not import RefMint or its parsing helpers. + +Both adapters receive identical input and the same original record, use shared UI/message/lookup code and produce identical reference strings. A low-level Bech32m decoder returns an HRP and words; an application still has to decide what those words mean. The baseline supplies those decisions instead of sending unchecked values to a database or being deliberately incomplete. + +This measures the work to implement **this particular v1 contract**. It does not show that all applications need this contract, or that RefMint is the best choice for short numeric check digits, other existing formats or machine-only IDs. Using Bech32 directly remains a reasonable choice. + +## Recorded evidence + +[comparison.json](comparison.json) contains the source hashes, methods, runtime environment, input outcomes, sizes and timings. Reproduce with `pnpm compare`; `pnpm test:comparison` verifies source hashes and deterministic facts without comparing machine timings or zlib-dependent compression sizes. + +| Question | What is measured | +| --- | --- | +| Does either implementation accept unsafe values? | 14 shared input scenarios for each of uint64 and UUID, including recomputed valid checksums on invalid versions, kinds, empty payloads and overflow | +| Does either miss the selected typos? | All alternative alphabet symbols in two fixed fixtures' data/checksum and lowercase alternatives in their namespace | +| Does it save application code? | Nonblank/noncomment lines of the complete adapters; shared UI, records and tests excluded | +| What library code is loaded? | Both the full RefMint root and actual codec-only import graph, plus adapter, versus the Bech32 package JS plus adapter; no minification/tree shaking; excludes shared UI and the local browser CJS wrapper | +| Are references shorter? | Original, canonical and grouped lengths for the same source values | +| What does parsing cost here? | Seven rounds in alternating order, same 28-case mix, warmup, diagnostics only; no I/O | + +Source for inspection: [RefMint adapter](https://github.com/PerchAnton/refmint/blob/main/examples/comparison/refmint-adapter.mjs), [independent Bech32m adapter](https://github.com/PerchAnton/refmint/blob/main/examples/comparison/bech32-adapter.mjs), [shared scenarios](https://github.com/PerchAnton/refmint/blob/main/examples/comparison/scenarios.mjs), [measurement script](https://github.com/PerchAnton/refmint/blob/main/scripts/compare.mjs). + +The RefMint adapter is smaller because the contract implementation is in its library. Count the library too before drawing a size conclusion. Neither this line count nor a synthetic mutation count measures maintainability, human error rate, support cost or adoption. Both implementations use zero-dependency libraries; the Bech32 package is only a development dependency of RefMint, not a runtime requirement. + +The numeric example grows from six source digits to 17 canonical characters; the UUID grows from 36 to 39. Both implementations have the same lengths. The extra underscore and two header symbols distinguish legacy codes, format version and value kind. A shorter checksum scheme may be a better trade-off for a short number. + +## Before and after optimization + +The baseline is still the same independent adapter, with the same inputs and error precedence. Runtime changes stream the checksum without expanded-prefix/combined-word arrays, precompute the 32 possible polynomial feedback values, collect raw offsets only on an invalid-character path, and convert at most ten base32 symbols (50 exact bits) per BigInt chunk. A uint64/UUID is never converted wholesale to a JS number. Frozen vectors, output shapes, Unicode rejection and both APIs are unchanged. + +The optional `refmint/codec` entry exports the same codec without importing the random generator and collision planner. The comparison form uses this entry's module graph; the original root import retains every API. The build removes implementation comments from JS only. Authored source, declaration JSDoc and license files are retained; names and whitespace are not minified. Bech32's published JS is measured unchanged. Thus size savings include packaging decisions, not only algorithm changes. + +Recorded on 2026-10-27 with Node 24.19.0 / Apple M3; bytes include each implementation's adapter: + +| Size | Previous RefMint root | Current RefMint root | Current codec-only form | Bech32m + adapter | +| --- | ---: | ---: | ---: | ---: | +| Unminified JS bytes | 15,255 | 14,147 | 8,773 | 9,327 | +| Summed per-file gzip bytes | 5,791 | 5,083 | 3,353 | 3,160 | + +The complete root is 7.3% smaller; the form's import is 42.5% smaller than before. Codec-only JS is 5.9% smaller than the baseline before compression, but **Bech32m remains smaller after gzip**. These are module sizes, not bundle, transfer or package-archive measurements. A bundler may remove unused functions from either implementation and change the comparison. + +[optimization.json](optimization.json) records a separate three-way run: previous revision `daaffe813fd0351752bd158314917267acddc1b6`, current build and Bech32m, all in one process. It uses the same 28 scenarios, 1,000 warmup passes per implementation, nine rotated-order rounds and 10,000 passes per round. Results are consumed. The medians were **556,352**, **1,779,391** and **822,457** checks/s respectively: 3.20× the previous RefMint and 2.16× this Bech32m adapter, for this workload. This longer three-way measurement differs from the original shorter two-way method retained in `pnpm compare`; neither is a general speed guarantee or browser measurement. + +All **123,146 differential comparisons** matched the previous build: encodings, formatting, both parse modes, diagnostics and raw offsets for numeric/UUID/legacy factories, including malformed UTF-16, truncation and length boundaries. The regular test suite also exhausts ASCII substitutions in canonical and displayed examples and tests every UUID bit boundary against the independent Bech32m decoder. + +To reproduce the historical comparison from a checkout containing that commit: + +```sh +git worktree add --detach ../refmint-before daaffe813fd0351752bd158314917267acddc1b6 +cd ../refmint-before +pnpm install --frozen-lockfile --ignore-scripts +pnpm build +cd ../refmint +pnpm build +pnpm compare +pnpm compare:previous ../refmint-before/dist daaffe813fd0351752bd158314917267acddc1b6 +pnpm test:comparison +``` + +Run sequentially on an otherwise idle machine. The previous-build path and revision are explicit inputs; the script does not download code. Both reports retain source hashes. `pnpm test:comparison` rejects stale current-runtime or baseline-adapter hashes, without asserting timing thresholds across machines. + +## Next decision + +This experiment can establish behavior and integration cost. A human-entry advantage requires a separate, consented study using the same task and comparable security/error-detection requirements, with task order counterbalanced. Measure completion time, unsuccessful attempts, wrong-record selections and abandonment; compare against the current workflow and a shorter-checksum baseline. Do not treat longer strings as automatically easier to use, or synthetic typo detection as a user study. If the extra length and dependency outweigh the application-code benefit, use the simpler solution. + +No study data or references are uploaded by this example. The page only reads its local static assets and recorded measurement file. Refreshing resets the example. diff --git a/docs/evidence.json b/docs/evidence.json index 9b2bc1b..4edb27a 100644 --- a/docs/evidence.json +++ b/docs/evidence.json @@ -1,6 +1,6 @@ { - "generated_at": "2026-10-27T08:56:50.253Z", - "version": "0.1.0-alpha.1", + "generated_at": "2026-10-27T13:21:13.955Z", + "version": "0.1.0-alpha.2", "environment": { "node": "v24.19.0", "platform": "darwin", @@ -24,23 +24,35 @@ "rejected": 4216 }, "size": { - "method": "Emitted JavaScript, not minified. gzip sizes are compressed per file and then summed; not a bundled transfer measurement.", + "method": "All emitted JavaScript, implementation comments removed but not minified; source and declaration documentation retained. gzip sizes are compressed per file and then summed; not a bundled transfer measurement.", "files": [ + { + "file": "dist/bech32m.js", + "bytes": 2143, + "gzip_bytes": 853, + "sha256": "8541415a0ca6dafede62789c1471afd39dcf91d034fb5aad897b8a1c1219dff2" + }, + { + "file": "dist/codec.js", + "bytes": 4593, + "gzip_bytes": 1582, + "sha256": "00fa6a5f15b53f69c421a6a36c9c37bf5125cb7390adced5073b8da8956e9ab5" + }, { "file": "dist/index.js", - "bytes": 6814, - "gzip_bytes": 2358, - "sha256": "c039862817354759fc4133111e86ccad559540b23d5d2082bd3ad80c825aaf37" + "bytes": 5374, + "gzip_bytes": 1730, + "sha256": "d805035cce7678ab09540d73cc7c898d9976593c794b4e5013f0ac9df6bfbbb6" }, { - "file": "dist/bech32m.js", - "bytes": 2358, - "gzip_bytes": 1133, - "sha256": "93d78425096f7dfb58c99c008c1256256e0a48d71d8d3b49a6f89a2bed3d62c6" + "file": "dist/presentation.js", + "bytes": 1867, + "gzip_bytes": 776, + "sha256": "c80609e88d8d10226e105599f2d57c4eeedde979a960d283b49070f765b91e25" } ], - "total_bytes": 9172, - "summed_gzip_bytes": 3491 + "total_bytes": 13977, + "summed_gzip_bytes": 4941 }, "timings": { "method": "Single process, seven rounds after warmup; no competitor comparison. Generation includes Web Crypto and checksum; parsing includes checksum and normalization.", @@ -49,30 +61,30 @@ "rounds": 7, "warmup": 2000, "ops_per_second": [ - 373641, - 420928, - 417591, - 420950, - 419686, - 417733, - 420127 + 746307, + 811310, + 810544, + 814124, + 812458, + 815989, + 811019 ], - "median_ops_per_second": 419686 + "median_ops_per_second": 811310 }, "parse_displayed": { "iterations": 20000, "rounds": 7, "warmup": 2000, "ops_per_second": [ - 485884, - 521867, - 522076, - 524402, - 523370, - 525297, - 521497 + 1356461, + 1421212, + 1462340, + 1435415, + 1461307, + 1399894, + 1402971 ], - "median_ops_per_second": 522076 + "median_ops_per_second": 1421212 } }, "collision_model": [ diff --git a/docs/evidence.md b/docs/evidence.md index 7946f8e..4b6ff79 100644 --- a/docs/evidence.md +++ b/docs/evidence.md @@ -10,15 +10,17 @@ The boundary suite extends public-API round trips to all 16 prefix lengths × 57 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. +Browser tests exercise the actual forms 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. Two additional tests per engine compare the existing-ID forms, both value kinds, all 14 shared cases, raw-input offsets and mobile layout: five tests per engine in total. Hosted results are available in the CI workflow. Local results and hosted results must be identified separately. `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. +`pnpm test:demo` uses an ephemeral loopback port. Malformed request targets must return HTTP 400 and leave the next normal request working; only explicitly listed demo/comparison assets and the local Bech32 module wrapper are served, with the expected MIME types and no caching. These tests cover the local development server, which is not a production hosting service. + +The existing-ID suite adds frozen format vectors, uint64 bit/precision boundaries, 1,000 deterministic values of each kind, invalid headers and valid checksums over out-of-range values. [Comparison methods](comparison.md) describe the independent Bech32m adapter, shared scenarios and separate measurements. ## Size and speed -Runtime dependency count comes from package.json. Emitted JavaScript bytes are measured without minification. gzip bytes are recorded for each module separately and summed; that sum is not a minified bundle benchmark. Source maps, declarations and documentation are not part of the JavaScript-only figure; the install archive is larger. +Runtime dependency count comes from package.json. Emitted JavaScript bytes are measured without minification; the build removes JS implementation comments while retaining source and declaration documentation. gzip bytes are recorded for each module separately and summed; that sum is not a minified bundle benchmark. Source maps, declarations and documentation are not part of the JavaScript-only figure; the install archive is larger. The README lifecycle graphic counts all runtime modules; the codec-only form's separate import graph is reported in [comparison methods](comparison.md#before-and-after-optimization). Timings use 2,000 warmup calls and seven rounds of 20,000 calls per operation in one Node process. The median and all raw samples are recorded. Generation includes Web Crypto and checksumming; parsing includes display normalization and validation. These are different operations, not comparable speed competitors. Hardware load, runtime and JIT affect results. There is no claim of being faster or smaller than Nano ID, UUID or another library. diff --git a/docs/format-v1.md b/docs/format-v1.md new file mode 100644 index 0000000..cd2b10b --- /dev/null +++ b/docs/format-v1.md @@ -0,0 +1,65 @@ +# Existing-ID format v1 + +This is a reversible representation of an existing public ID. It allocates no random ID and requires no alias column. It uses standard Bech32m; the application contract below is RefMint-specific. It does not hide the original value, preserve string sort order, or provide authorization. + +## Wire contract + +`namespace + "_1" + version + kind + payload + checksum` + +All data symbols use `qpzry9x8gf2tvdw0s3jn54khce6mua7l`, indexed from 0 to 31. + +| Part | Rule | +| --- | --- | +| Namespace | 1–16 lowercase ASCII letters/digits, starting with a letter | +| Bech32m HRP | Namespace followed by `_`; the last `1` separates HRP and data | +| Version | One symbol: integer 1, encoded as `p` | +| Kind | One symbol: integer 0 (`q`) for uint64, 1 (`p`) for UUID | +| uint64 payload | Unsigned integer 0 through 2^64−1 in minimal-width big-endian base32; 1–13 symbols; zero is one `q` | +| UUID payload | Exactly 26 big-endian base32 symbols for all 128 bits; prepend two zero bits; first symbol value must be ≤7 | +| Checksum | Standard six-symbol Bech32m with constant `0x2bc830a3`, over the complete HRP and data | + +No unused high bits may be set. Leading zero symbols on a multi-symbol uint64 are invalid even when their checksum is valid. UUID leading zero symbols are required to preserve its fixed 128-bit width. UUID support preserves bits; it does not validate the version/variant semantics or generate a UUID. Nil and all-ones UUIDs are representable. + +Canonical lengths, including namespace and checksum: `namespace.length + 10 + payload.length`. The maximum is 52 characters (16-character namespace and UUID payload). Grouped display adds hyphens after each four body symbols, counting version, kind, payload and checksum together. Whole-code uppercase and surrounding ASCII space/tab/CR/LF are accepted only at human-input boundaries. Other normalization and checksum repair are forbidden. + +## API + +```ts +import { createReferenceCodec } from 'refmint' + +const returns = createReferenceCodec({ prefix: 'rma', kind: 'uint64', version: 1 }) +const reference = returns.encode(123456n) +const result = returns.decode(returns.format(reference).toUpperCase()) +if (result.ok) { + const original: bigint = result.value + // Query using original, with the application's authorization rules. +} +``` + +`kind: 'uuid'` accepts and returns the canonical lowercase hexadecimal UUID string with hyphens. `kind: 'uint64'` requires `bigint`; JavaScript numbers are rejected even when currently safe, so the contract never changes with value magnitude. Convert database values explicitly according to the database driver's type rules. A numeric codec does not preserve leading zeros from a text identifier; identifiers such as `000042` need a different application contract and are not supported here. + +The factory snapshots and validates `prefix`, `kind` and `version` once, rejects unknown options and returns an immutable object. Version defaults to 1. An unsupported version in configuration throws. Encoding invalid values throws; decoding unknown input returns a discriminated result. + +- `encode(value)` → `EncodedReference`. +- `decode(input)` → `{ ok: true, id, value, normalized }` or `{ ok: false, error }`. +- `decodeCanonical(input)` → same result, requiring the exact stored representation. +- `diagnose(input)` → same successful result, or `{ ok: false, error, details }`. +- `format(id)` → grouped display; rejects references from another namespace/kind or noncanonical input at runtime. + +Namespace and kind are both part of the TypeScript brand. Existing random `Reference

` values have a different brand. + +## Validation order + +Use the common [input presentation rules](../SPEC.md#parsing-and-error-precedence): type/128-code-unit bound, ASCII, case, HRP grammar, exact grouping, alphabet, then checksum. For this format the HRP grammar requires the trailing underscore. + +After a valid checksum: check namespace; require two header symbols; check version; check kind; check payload length; check numeric overflow; reject a nonminimal uint64; then apply strict canonical-input rules when requested. Unknown kinds/versions are rejected, never interpreted as legacy payloads. Neither database existence nor authorization is tested by a decoder. + +`DecodeError` includes the existing parser errors plus `version`, `kind`, and `overflow`. Diagnostics can contain `positions`, `expected`, `actual`, and `unit`. Positions are zero-based UTF-16 offsets in the **original** input, including surrounding whitespace and grouping. Non-BMP characters report the offset of their first surrogate. Length units are explicit: `input_code_units`, `data_symbols` (header included), or `payload_symbols` (header/checksum excluded). Positions are supplied only for known invalid characters, not guessed checksum-error locations. Checksum failures have empty details. + +## Compatibility commitment + +The v1 layout, canonical encoding and accepted presentation forms defined here are frozen. Changes to that wire contract require a new version and reader; they must not change v1 output. Package APIs may continue to evolve during alpha. Explicit versioning does not authorize removal of v1 readers from deployed applications. + +The alpha.1 random-reference format remains available through `createReferences`, with its generation, parsing and error-result shapes preserved. Its HRP grammar prohibits underscores. The new HRP requires one, so the formats are structurally disjoint even when the namespace and data lengths happen to match. Each API rejects the other's format. Do not fall back from a failed v1 parse to guessing a legacy ID or another version. + +Frozen compatibility vectors live in [tests/fixtures/formats-v1.json](https://github.com/PerchAnton/refmint/blob/main/tests/fixtures/formats-v1.json). They cover small and maximal uint64 values, the JavaScript precision boundary, UUID bit preservation, a namespace containing separator digits, and legacy codes. They were generated once with the independent `bech32@2.0.0` implementation and radix-32 conversion. Tests compare exact text and recovered values; do not regenerate vectors to make an incompatible implementation pass. diff --git a/docs/optimization.json b/docs/optimization.json new file mode 100644 index 0000000..781c6f3 --- /dev/null +++ b/docs/optimization.json @@ -0,0 +1,117 @@ +{ + "generated_at": "2026-10-27T12:53:09.779Z", + "environment": { + "node": "v24.19.0", + "platform": "darwin", + "arch": "arm64", + "cpu": "Apple M3" + }, + "previous": { + "revision": "daaffe813fd0351752bd158314917267acddc1b6", + "files": [ + { + "file": "bech32m.js", + "bytes": 2358, + "sha256": "93d78425096f7dfb58c99c008c1256256e0a48d71d8d3b49a6f89a2bed3d62c6" + }, + { + "file": "codec.js", + "bytes": 4699, + "sha256": "daf48d1f55c60fb0add428612f2f31d9dd3ee217cac8c0180427b8ae119ab17a" + }, + { + "file": "index.js", + "bytes": 6046, + "sha256": "29c2c02934f48e4a82961bd1d69488f30fb143eca2809695711e1664d5b7f579" + }, + { + "file": "presentation.js", + "bytes": 1982, + "sha256": "7e1169ce664c1b649c800e8494341d0a36dd478b8edcd077a37d35b2bed2e1af" + } + ] + }, + "current": { + "files": [ + { + "file": "bech32m.js", + "bytes": 2143, + "sha256": "8541415a0ca6dafede62789c1471afd39dcf91d034fb5aad897b8a1c1219dff2" + }, + { + "file": "codec.js", + "bytes": 4593, + "sha256": "00fa6a5f15b53f69c421a6a36c9c37bf5125cb7390adced5073b8da8956e9ab5" + }, + { + "file": "index.js", + "bytes": 5374, + "sha256": "d805035cce7678ab09540d73cc7c898d9976593c794b4e5013f0ac9df6bfbbb6" + }, + { + "file": "presentation.js", + "bytes": 1867, + "sha256": "c80609e88d8d10226e105599f2d57c4eeedde979a960d283b49070f765b91e25" + } + ] + }, + "baseline": { + "package": "bech32@2.0.0", + "adapter_sha256": "f6049af2f2bd715db101ea927960c6b1bf4aa73f4f6ffa1ea22006e6de12997e" + }, + "differential": { + "comparisons": 123146, + "method": "3000 seeded values per uint64/UUID/legacy factory, generated encodings, formatting, canonical/displayed/mutated/truncated/overlong inputs, all parse modes and raw diagnostics; plus non-string and malformed UTF-16 inputs." + }, + "timing": { + "method": "9 rotated-order rounds in one process, 1000 warmup passes per implementation, 10000 passes per round over the same 28 scenarios. Results consumed; no I/O.", + "previous": { + "ops_per_second": [ + 549753, + 554969, + 557097, + 557648, + 556352, + 558002, + 557269, + 553766, + 554677 + ], + "median_ops_per_second": 556352 + }, + "current": { + "ops_per_second": [ + 1764209, + 1766824, + 1792377, + 1779391, + 1784077, + 1779454, + 1786484, + 1773945, + 1762532 + ], + "median_ops_per_second": 1779391 + }, + "bech32": { + "ops_per_second": [ + 823928, + 820908, + 811623, + 826322, + 822457, + 823742, + 820679, + 823366, + 821034 + ], + "median_ops_per_second": 822457 + }, + "consumed": 97734000 + }, + "limits": [ + "One machine and one input distribution; not a browser, database or user study.", + "The main comparison retains its original shorter two-way timing method; its medians can differ from this three-way run.", + "Full-root and codec-only sizes are separate in comparison.json. JS comments were removed from RefMint outputs only; source/declaration docs and all APIs are retained." + ] +} diff --git a/examples/browser/index.html b/examples/browser/index.html index 1f7cce7..d906c5e 100644 --- a/examples/browser/index.html +++ b/examples/browser/index.html @@ -41,7 +41,7 @@

Alpha · local demo

A reference people can enter.
An error you can explain.

-

Create a ticket, copy its printed reference, and try a typo. The lookup distinguishes a mistyped code from a valid code with no matching record.

+

Create a ticket, copy its printed reference, and try a typo. The lookup distinguishes a mistyped code from a valid code with no matching record. Compare existing-ID forms with Bech32m →

01 / Create
diff --git a/examples/comparison/app.mjs b/examples/comparison/app.mjs new file mode 100644 index 0000000..c13cbef --- /dev/null +++ b/examples/comparison/app.mjs @@ -0,0 +1,85 @@ +import { bech32m } from '/vendor/bech32.mjs'; +import { createRefMintAdapter } from './refmint-adapter.mjs'; +import { createBech32Adapter } from './bech32-adapter.mjs'; +import { scenarios, lookup, sampleUUID } from './scenarios.mjs'; + +const $ = selector => document.querySelector(selector); +let kind, adapters, cases; +const json = value => JSON.stringify(value, (_, v) => typeof v === 'bigint' ? v.toString() + 'n' : v, 2); +function message(result) { + if (result.status === 'found') return 'Return found: received. The original database key was restored.'; + if (result.status === 'unknown') return 'Valid reference, but no matching return exists in this sample.'; + const { error, details } = result.parsed; + if (error === 'character') return `Unsupported character at position ${details.positions.map(n => n + 1).join(', ')}. Compare with the original; nothing was corrected.`; + if (error === 'length') return `Wrong payload length: expected ${details.expected}, received ${details.actual} (${details.unit}).`; + if (error === 'prefix') return `This reference belongs to “${details.actual}”, not “${details.expected}”.`; + if (error === 'version') return `Format version ${details.actual} is not supported. This form accepts version ${details.expected}.`; + if (error === 'kind') return `This reference holds ${details.actual}; this form expects ${details.expected}.`; + return { + checksum: 'The typo check failed. Compare the entire reference with the original; the wrong position is not known.', + case: 'Use all lowercase or all uppercase letters.', + format: 'Check the prefix, separator and groups of four.', + overflow: 'The encoded value is outside the supported ID range.', + non_canonical: 'This code has an unsupported representation of the ID.', + too_long: 'The input is too long. No lookup was made.', + type: 'Enter a text reference.' + }[error]; +} +function compare() { + for (const [name, adapter] of Object.entries(adapters)) { + const result = lookup(adapter, $('#input').value, kind); + $(`#${name}-result`).textContent = message(result); + $(`#${name}-result`).dataset.state = result.status; + $(`#${name}-queries`).textContent = `Lookup calls for this submission: ${result.queries}`; + $(`#${name}-details`).textContent = json(result.parsed); + } +} +function reset() { + kind = $('#kind').value; + adapters = { refmint: createRefMintAdapter({ prefix: 'rma', kind }), bech32: createBech32Adapter(bech32m, { prefix: 'rma', kind }) }; + cases = scenarios(adapters.refmint, kind, bech32m); + const value = kind === 'uint64' ? 123456n : sampleUUID; + const reference = adapters.refmint.encode(value); + $('#original').textContent = String(value); + $('#reference').textContent = adapters.refmint.format(reference).toUpperCase(); + $('#length').textContent = `${String(value).length} original characters → ${reference.length} canonical / ${adapters.refmint.format(reference).length} printed characters. Same length in both forms.`; + $('#input').value = ''; + $('#matrix').replaceChildren(); + $('#matrix-status').textContent = 'Run the shared cases to compare outcomes.'; + for (const name of Object.keys(adapters)) { + $(`#${name}-result`).textContent = 'Ready to check.'; + $(`#${name}-result`).dataset.state = 'idle'; + $(`#${name}-queries`).textContent = 'Lookup calls: 0'; + $(`#${name}-details`).textContent = '—'; + } +} +$('#kind').addEventListener('change', reset); +$('#compare').addEventListener('submit', event => { event.preventDefault(); compare(); }); +for (const [id, name] of [['correct', 'Printed uppercase'], ['typo', 'Changed symbol'], ['illegal', 'Illegal character'], ['unknown', 'Valid but unknown']]) { + $('#' + id).addEventListener('click', () => { $('#input').value = cases.find(s => s.name === name).input; compare(); }); +} +$('#run').addEventListener('click', () => { + $('#matrix').replaceChildren(); + let matched = 0; + for (const s of cases) { + const a = lookup(adapters.refmint, s.input, kind); + const b = lookup(adapters.bech32, s.input, kind); + const equal = json(a) === json(b); + const expected = a.status === s.status && (!s.error || a.parsed.error === s.error); + if (equal && expected) matched++; + const row = document.createElement('tr'); + for (const text of [s.name, a.status === 'invalid' ? a.parsed.error : a.status, b.status === 'invalid' ? b.parsed.error : b.status, equal && expected ? 'Pass' : 'FAIL']) { + const cell = document.createElement('td'); cell.textContent = text; row.append(cell); + } + $('#matrix').append(row); + } + $('#matrix-status').textContent = `${matched}/${cases.length} scenarios match the expected outcome in both forms.`; +}); +reset(); +try { + const response = await fetch('/docs/comparison.json'); + if (!response.ok) throw new Error('Evidence is not built. Run pnpm compare.'); + const report = await response.json(); + const code = report.loaded_code; + $('#metrics').textContent = `Recorded ${report.generated_at.slice(0, 10)} on ${report.environment.node}. Adapter source: ${report.adapters.refmint.code_lines} RefMint lines, ${report.adapters.bech32.code_lines} Bech32m adapter lines (nonblank, noncomment). This form loads the codec-only RefMint import: ${code.refmint_codec.bytes} vs ${code.bech32.bytes} bytes including adapters; summed per-file gzip: ${code.refmint_codec.summed_gzip_bytes} vs ${code.bech32.summed_gzip_bytes} bytes. The complete RefMint root import is ${code.refmint.bytes} bytes. No minification or tree shaking; RefMint removes implementation comments from JS, retaining source/type documentation. Local median diagnostic checks/second: ${report.timing.refmint.median_ops_per_second} vs ${report.timing.bech32.median_ops_per_second}. Timings depend on the machine and input mix; gzip sums are not bundled transfer sizes.`; +} catch (error) { $('#metrics').textContent = error.message; } diff --git a/examples/comparison/bech32-adapter.mjs b/examples/comparison/bech32-adapter.mjs new file mode 100644 index 0000000..3c923e6 --- /dev/null +++ b/examples/comparison/bech32-adapter.mjs @@ -0,0 +1,63 @@ +// Independent application adapter around bech32@2.0.0, matching RefMint v1. +// No RefMint imports. Both implementations receive the same input contract. +export function createBech32Adapter(bech32m, { prefix, kind }) { + const alphabet = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; + const tag = kind === 'uint64' ? 0 : 1; + const bits = kind === 'uint64' ? 64n : 128n; + if (!/^[a-z][a-z0-9]{0,15}$/.test(prefix) || !['uint64', 'uuid'].includes(kind)) throw new TypeError('Invalid configuration'); + function diagnose(input) { + const fail = (error, details = {}) => ({ ok: false, error, details }); + if (typeof input !== 'string') return fail('type'); + if (input.length > 128) return fail('too_long', { expected: 128, actual: input.length, unit: 'input_code_units' }); + const nonAscii = []; + let pos = 0; + for (const ch of input) { if (ch.codePointAt(0) > 127) nonAscii.push(pos); pos += ch.length; } + if (nonAscii.length) return fail('character', { positions: nonAscii }); + const offset = input.match(/^[ \t\r\n]*/)[0].length; + let text = input.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, ''); + if (text !== text.toLowerCase() && text !== text.toUpperCase()) return fail('case'); + text = text.toLowerCase(); + const sep = text.lastIndexOf('1'); + if (sep < 1 || !/^[a-z][a-z0-9]{0,15}_$/.test(text.slice(0, sep))) return fail('format'); + const body = text.slice(sep + 1); + const groups = body.split('-'); + if (groups.length > 1 && groups.some((g, i) => i < groups.length - 1 ? g.length !== 4 : g.length < 1 || g.length > 4)) return fail('format'); + const positions = []; + for (let i = 0; i < body.length; i++) if (body[i] !== '-' && !alphabet.includes(body[i])) positions.push(offset + sep + 1 + i); + if (positions.length) return fail('character', { positions }); + text = text.slice(0, sep + 1) + groups.join(''); + let decoded; + try { decoded = bech32m.decode(text); } catch { return fail('checksum'); } + if (decoded.prefix !== prefix + '_') return fail('prefix', { expected: prefix, actual: decoded.prefix.slice(0, -1) }); + const { words } = decoded; + if (words.length < 2) return fail('length', { expected: 'at least 2', actual: words.length, unit: 'data_symbols' }); + if (words[0] !== 1) return fail('version', { expected: 1, actual: words[0] }); + if (words[1] !== tag) return fail('kind', { expected: kind, actual: words[1] === 0 ? 'uint64' : words[1] === 1 ? 'uuid' : words[1] }); + const data = words.slice(2); + if (kind === 'uuid' ? data.length !== 26 : data.length < 1 || data.length > 13) return fail('length', { expected: kind === 'uuid' ? 26 : '1–13', actual: data.length, unit: 'payload_symbols' }); + const n = data.reduce((value, word) => value * 32n + BigInt(word), 0n); + if (n >= 2n ** bits) return fail('overflow'); + if (kind === 'uint64' && data.length > 1 && data[0] === 0) return fail('non_canonical'); + const hex = n.toString(16).padStart(32, '0'); + const value = kind === 'uint64' ? n : [hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16), hex.slice(16, 20), hex.slice(20)].join('-'); + return { ok: true, id: text, value, normalized: text !== input }; + } + function encode(input) { + if (kind === 'uint64' ? typeof input !== 'bigint' : typeof input !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(input)) throw new TypeError('Invalid source ID'); + let n = kind === 'uint64' ? input : BigInt('0x' + input.replaceAll('-', '')); + if (n < 0n || n >= 2n ** bits) throw new RangeError('Source ID out of range'); + const words = []; + do { words.unshift(Number(n % 32n)); n /= 32n; } while (n > 0n); + if (kind === 'uuid') while (words.length < 26) words.unshift(0); + return bech32m.encode(prefix + '_', [1, tag, ...words]); + } + return { + encode, diagnose, + format(id) { + const p = diagnose(id); + if (!p.ok || p.normalized) throw new TypeError('Expected a canonical reference'); + const start = id.lastIndexOf('1') + 1; + return id.slice(0, start) + id.slice(start).match(/.{1,4}/g).join('-'); + } + }; +} diff --git a/examples/comparison/index.html b/examples/comparison/index.html new file mode 100644 index 0000000..d43961d --- /dev/null +++ b/examples/comparison/index.html @@ -0,0 +1,35 @@ + + + + +RefMint · Existing ID comparison + +
+
RefMintLocal comparison · format v1
+

Keep the record.
Check the reference.

+

Compare RefMint with a Bech32m application adapter. Both forms use the same existing ID, printed code, validation rules and sample record.

+
+

Sample return / received

A document number you can check

+ +

Original database key:

+
Printed reference

+
+
+

Try the return lookup

+

Uppercase and exact groups of four are accepted. Characters are never silently corrected.

+
+
+

Library contract

RefMint

Ready to check.

Lookup calls: 0

Parsed result
—
+

Codec + application rules

Bech32m + adapter

Ready to check.

Lookup calls: 0

Parsed result
—
+
+

Lookup calls count access to the in-memory sample record. Neither form makes a database or network request. Production code must authorize every lookup.

+
+

Reproducible checks

Compare the whole input contract

+

Run the shared cases to compare outcomes.

+
InputRefMintBech32m + adapterMatch
+
Integration size and measurement limits
Loading recorded measurements…

The line count measures these adapters implementing this exact contract, not general library quality. Both use the same checksum and have the same reference length. No human typing study or production speed advantage has been established.

RefMint adapter · Bech32m adapter · Recorded evidence

+
+ +
+ + diff --git a/examples/comparison/refmint-adapter.mjs b/examples/comparison/refmint-adapter.mjs new file mode 100644 index 0000000..46458b9 --- /dev/null +++ b/examples/comparison/refmint-adapter.mjs @@ -0,0 +1,5 @@ +import { createReferenceCodec } from '../../dist/codec.js'; + +export function createRefMintAdapter(options) { + return createReferenceCodec({ ...options, version: 1 }); +} diff --git a/examples/comparison/scenarios.mjs b/examples/comparison/scenarios.mjs new file mode 100644 index 0000000..a221a97 --- /dev/null +++ b/examples/comparison/scenarios.mjs @@ -0,0 +1,32 @@ +// Shared fixture construction and record lookup; neither adapter implements these. +export const sampleUUID = '550e8400-e29b-41d4-a716-446655440000'; +export function scenarios(adapter, kind, bech32m) { + const value = kind === 'uint64' ? 123456n : sampleUUID; + const canonical = adapter.encode(value); + const { prefix, words } = bech32m.decode(canonical); + const bodyStart = canonical.lastIndexOf('1') + 1; + const changed = canonical.slice(0, -1) + (canonical.endsWith('q') ? 'p' : 'q'); + return [ + { name: 'Canonical', input: canonical, status: 'found' }, + { name: 'Printed uppercase', input: adapter.format(canonical).toUpperCase(), status: 'found' }, + { name: 'Surrounding whitespace', input: ' \t' + adapter.format(canonical) + '\r\n', status: 'found' }, + { name: 'Changed symbol', input: changed, status: 'invalid', error: 'checksum' }, + { name: 'Illegal character', input: canonical.slice(0, bodyStart) + 'b' + canonical.slice(bodyStart + 1), status: 'invalid', error: 'character' }, + { name: 'Unicode lookalike', input: canonical.replace('r', 'r'), status: 'invalid', error: 'character' }, + { name: 'Mixed case', input: 'R' + canonical.slice(1), status: 'invalid', error: 'case' }, + { name: 'Broken grouping', input: canonical + '-', status: 'invalid', error: 'format' }, + { name: 'Wrong namespace', input: bech32m.encode('doc_', words), status: 'invalid', error: 'prefix' }, + { name: 'Unknown version', input: bech32m.encode(prefix, [2, ...words.slice(1)]), status: 'invalid', error: 'version' }, + { name: 'Wrong value kind', input: bech32m.encode(prefix, [1, kind === 'uint64' ? 1 : 0, ...words.slice(2)]), status: 'invalid', error: 'kind' }, + { name: 'Missing payload', input: bech32m.encode(prefix, [1, words[1]]), status: 'invalid', error: 'length' }, + { name: 'Overflow', input: bech32m.encode(prefix, [1, words[1], kind === 'uint64' ? 16 : 8, ...Array(kind === 'uint64' ? 12 : 25).fill(0)]), status: 'invalid', error: 'overflow' }, + { name: 'Valid but unknown', input: adapter.encode(kind === 'uint64' ? 999999n : sampleUUID.slice(0, -1) + '1'), status: 'unknown' } + ]; +} + +export function lookup(adapter, input, kind) { + const parsed = adapter.diagnose(input); + if (!parsed.ok) return { status: 'invalid', queries: 0, parsed }; + const original = kind === 'uint64' ? 123456n : sampleUUID; + return { status: parsed.value === original ? 'found' : 'unknown', queries: 1, parsed }; +} diff --git a/examples/comparison/style.css b/examples/comparison/style.css new file mode 100644 index 0000000..552194f --- /dev/null +++ b/examples/comparison/style.css @@ -0,0 +1,48 @@ +:root { color-scheme: light; font: 16px/1.55 system-ui, sans-serif; color: #183a31; background: #f4f3eb; } +* { box-sizing: border-box; } +body { margin: 0; } +main { max-width: 1120px; padding: 34px 28px 20px; margin: auto; } +header, .section-head { display: flex; justify-content: space-between; align-items: center; gap: 18px; flex-wrap: wrap; } +a { color: #09694e; } +.brand { font-size: 28px; font-weight: 800; text-decoration: none; letter-spacing: -1px; } +.brand span { color: #0b8861; } +.badge { font-size: 12px; padding: 7px 12px; border: 1px solid #bbc9bb; border-radius: 20px; } +h1 { font-size: clamp(38px, 6vw, 64px); letter-spacing: -2px; line-height: 1.08; margin: 45px 0 20px; } +h2 { font-size: 22px; line-height: 1.3; margin: 0 0 18px; } +h3 { font-size: 23px; margin: 0 0 16px; } +.intro { font-size: 18px; max-width: 760px; color: #53685c; margin-bottom: 28px; } +section { padding: 28px; margin-bottom: 22px; background: white; border: 1px solid #d8ded0; border-radius: 18px; } +.receipt { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; background: #173d31; color: #f4f6eb; } +.receipt .small, .receipt .eyebrow { color: #c7decb; } +.eyebrow { text-transform: uppercase; letter-spacing: 1.4px; font-size: 11px; color: #5b7464; margin: 0 0 10px; } +.printed { padding: 22px; background: #28503e; border-radius: 12px; align-self: center; min-width: 0; } +output { display: block; font: 600 20px/1.7 ui-monospace, monospace; overflow-wrap: anywhere; margin-top: 10px; } +code, pre { font-family: ui-monospace, monospace; overflow-wrap: anywhere; white-space: pre-wrap; } +pre { font-size: 12px; } +.small { color: #587062; font-size: 13px; } +label { display: block; font-weight: 600; margin: 10px 0 7px; } +input, select, button { font: inherit; border: 1px solid #a6bcac; border-radius: 8px; } +input { font-family: ui-monospace, monospace; width: 100%; min-width: 0; padding: 12px; } +select { padding: 7px 10px; max-width: 100%; } +button { padding: 10px 14px; background: #fff; color: #244b37; cursor: pointer; } +button:hover { background: #eaf2e8; } +.primary { background: #08694f; color: #fff; border-color: #08694f; white-space: nowrap; } +.primary:hover { background: #07563f; } +:focus-visible { outline: 3px solid #d68a28; outline-offset: 3px; } +.input-row { display: flex; gap: 10px; } +.actions { display: flex; flex-wrap: wrap; gap: 9px; margin: 18px 0; } +.actions button { font-size: 13px; } +.results { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-top: 24px; } +article { min-width: 0; padding: 22px; border: 1px solid #d8e2d7; border-radius: 12px; background: #f9fbf6; } +.result { min-height: 90px; border-left: 3px solid #9eafa1; padding-left: 13px; } +.result[data-state="found"] { border-color: #168451; } +.result[data-state="invalid"] { border-color: #be6a24; } +.result[data-state="unknown"] { border-color: #55788f; } +details { margin-top: 18px; } +summary { cursor: pointer; font-weight: 600; } +.table-scroll { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: 13px; } +th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid #e0e6da; } +footer { font-size: 13px; color: #52675c; padding: 4px 3px; } +#fatal { color: #9b3618; } +@media (max-width: 720px) { main { padding: 22px 16px; } section { padding: 20px; } .receipt, .results { grid-template-columns: 1fr; } .input-row { flex-direction: column; } h1 { margin-top: 32px; } output { font-size: 17px; } } diff --git a/package.json b/package.json index 936cce1..55c05b7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "refmint", - "version": "0.1.0-alpha.1", - "description": "Generate, format and validate public reference codes with a collision budget and Bech32m checksum.", + "version": "0.1.0-alpha.2", + "description": "Generate public references or encode existing IDs with strict input diagnostics and a Bech32m checksum.", "type": "module", "license": "MIT OR Apache-2.0", "author": "PerchAnton", @@ -27,6 +27,10 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./codec": { + "types": "./dist/codec.d.ts", + "import": "./dist/codec.js" } }, "files": [ @@ -50,8 +54,11 @@ "check": "pnpm build && pnpm test && pnpm test:types", "example": "node examples/ticket-lookup.mjs", "evidence": "node scripts/evidence.mjs", + "compare": "node scripts/compare.mjs", + "compare:previous": "node scripts/compare-previous.mjs", "demo": "node scripts/serve.mjs", "test:browser": "playwright test", + "test:comparison": "node scripts/compare.mjs --check", "prepack": "pnpm build" }, "packageManager": "pnpm@11.25.0", diff --git a/playwright.config.mjs b/playwright.config.mjs index a98c469..2719f51 100644 --- a/playwright.config.mjs +++ b/playwright.config.mjs @@ -1,8 +1,9 @@ import { defineConfig } from '@playwright/test'; +const url = `http://127.0.0.1:${process.env.REFMINT_PORT ?? 4173}`; export default defineConfig({ testDir: './tests/browser', - use: { baseURL: 'http://127.0.0.1:4173', headless: true }, - webServer: { command: 'node scripts/serve.mjs', url: 'http://127.0.0.1:4173', reuseExistingServer: !process.env.CI }, + use: { baseURL: url, headless: true }, + webServer: { command: 'node scripts/serve.mjs', url, reuseExistingServer: false }, projects: [ { name: 'chromium', use: { browserName: 'chromium' } }, { name: 'firefox', use: { browserName: 'firefox' } }, diff --git a/scripts/build.mjs b/scripts/build.mjs index f0b3286..7b1bcd7 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -1,6 +1,7 @@ -import { rm } from 'node:fs/promises'; +import { rm, readdir, readFile, writeFile } from 'node:fs/promises'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; const root = new URL('..', import.meta.url); // TypeScript does not remove outputs of deleted or renamed source files. @@ -10,3 +11,14 @@ const result = spawnSync(process.execPath, [ ], { cwd: fileURLToPath(root), stdio: 'inherit' }); if (result.error) throw result.error; process.exitCode = result.status ?? 1; +// Strip implementation comments from JS only. Keep authored source and JSDoc +// in .d.ts; no minification, renamed symbols, dependency or runtime helper. +if (result.status === 0) { + const printer = ts.createPrinter({ removeComments: true, newLine: ts.NewLineKind.LineFeed }); + for (const name of await readdir(new URL('dist/', root))) { + if (!name.endsWith('.js')) continue; + const path = new URL('dist/' + name, root); + const source = ts.createSourceFile(name, await readFile(path, 'utf8'), ts.ScriptTarget.ES2022, true, ts.ScriptKind.JS); + await writeFile(path, printer.printFile(source)); + } +} diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs index 41d89a3..0afeb82 100644 --- a/scripts/check-package.mjs +++ b/scripts/check-package.mjs @@ -43,13 +43,15 @@ try { 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'); + ['bech32m.d.ts', 'bech32m.js', 'codec.d.ts', 'codec.js', 'index.d.ts', 'index.js', 'presentation.d.ts', 'presentation.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); } + assert.match(await readFile(join(installed, 'dist/index.d.ts'), 'utf8'), /A validated canonical reference/, 'Retain declaration documentation'); const evidence = JSON.parse(await readFile(join(installed, 'docs/evidence.json'), 'utf8')); assert.equal(evidence.version, source.version, 'Refresh evidence for this version'); + assert.deepEqual(evidence.size.files.map(file => file.file).sort(), (await readdir(join(installed, 'dist'))).filter(file => file.endsWith('.js')).map(file => 'dist/' + file).sort()); for (const file of evidence.size.files) { const bytes = await readFile(join(installed, file.file)); assert.equal(createHash('sha256').update(bytes).digest('hex'), file.sha256, 'Runtime changed: regenerate evidence'); @@ -66,7 +68,9 @@ try { } await writeFile(join(consumer, 'smoke.mjs'), ` import assert from 'node:assert/strict'; -import { createReferences, planReferences, collisionProbability } from 'refmint'; +import { createReferences, createReferenceCodec, planReferences, collisionProbability } from 'refmint'; +import { createReferenceCodec as standalone } from 'refmint/codec'; +assert.equal(standalone, createReferenceCodec); const refs = createReferences({prefix:'ticket', expectedCount:1000000, maxCollisionProbability:1e-9}); const id = refs.generate(); assert.equal(refs.parse(refs.format(id).toUpperCase()).id, id); @@ -74,11 +78,19 @@ assert.equal(refs.parseCanonical(refs.format(id)).error, 'non_canonical'); assert.equal(refs.parse(id.toUpperCase().replace('K', '\\u212a')).error, 'character'); assert.equal(planReferences({prefix:'ticket'}).randomBits, 130); assert.ok(collisionProbability(14, 1000000) < 1e-9); +const codec = createReferenceCodec({prefix:'rma',kind:'uint64',version:1}); +const encoded = codec.encode(9007199254740993n); +assert.equal(codec.decode(codec.format(encoded).toUpperCase()).value, 9007199254740993n); +assert.equal(codec.diagnose(encoded.replace('r', '\\uff52')).details.positions[0], 0); +assert.equal(refs.parse(encoded).ok, false); await assert.rejects(import('refmint/dist/bech32m.js'), {code:'ERR_PACKAGE_PATH_NOT_EXPORTED'}); `); run(join(consumer, 'smoke.mjs'), [], consumer); await writeFile(join(consumer, 'types.mts'), ` -import { createReferences, type Reference } from 'refmint'; +import { createReferences, createReferenceCodec, type Reference } from 'refmint'; +import { createReferenceCodec as standalone, type EncodedReference } from 'refmint/codec'; +const standaloneId: EncodedReference<'rma', 'uint64'> = standalone({prefix:'rma',kind:'uint64'}).encode(42n); +createReferenceCodec({prefix:'rma',kind:'uint64'}).format(standaloneId); const docs = createReferences({prefix:'doc'}); const ticket: Reference<'ticket'> = createReferences({prefix:'ticket'}).generate(); // @ts-expect-error The installed declarations must retain namespace branding. @@ -87,6 +99,15 @@ docs.format(ticket); docs.format('anything'); const result = docs.parse('anything'); if (result.ok) docs.format(result.id); +const numbers = createReferenceCodec({prefix:'rma',kind:'uint64'}); +const uuid = createReferenceCodec({prefix:'rma',kind:'uuid'}); +const n = numbers.encode(1n); +// @ts-expect-error Codec kinds must not be mixed in the installed declarations. +uuid.format(n); +// @ts-expect-error uint64 never accepts a floating-point number. +numbers.encode(1); +const decoded = numbers.decode(n); +if (decoded.ok) { const original: bigint = decoded.value; void original; } `); 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.`); diff --git a/scripts/compare-previous.mjs b/scripts/compare-previous.mjs new file mode 100644 index 0000000..fe1f547 --- /dev/null +++ b/scripts/compare-previous.mjs @@ -0,0 +1,99 @@ +// Compare a built previous checkout, the current build and unchanged Bech32m +// in one process. Nothing is fetched or executed from GitHub by this command. +import assert from 'node:assert/strict'; +import { readFile, readdir, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { resolve, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import os from 'node:os'; +import { bech32m } from 'bech32'; +import * as current from '../dist/index.js'; +import { createBech32Adapter } from '../examples/comparison/bech32-adapter.mjs'; +import { scenarios } from '../examples/comparison/scenarios.mjs'; + +const previousDist = process.argv[2]; +const revision = process.argv[3]; +assert.ok(previousDist && /^[0-9a-f]{40}$/.test(revision ?? ''), + 'Usage: pnpm compare:previous /path/to/previous/dist FULL_COMMIT_SHA'); +const previous = await import(pathToFileURL(resolve(previousDist, 'index.js')).href); +const report = JSON.parse(await readFile('docs/comparison.json', 'utf8')); +const hash = bytes => createHash('sha256').update(bytes).digest('hex'); +async function sources(directory) { + return Promise.all((await readdir(directory)).filter(f => f.endsWith('.js')).sort().map(async name => { + const bytes = await readFile(join(directory, name)); + return { file: name, bytes: bytes.length, sha256: hash(bytes) }; + })); +} +const jobs = []; +let comparisons = 0; +const countEqual = (a, b) => { assert.deepEqual(a, b); comparisons++; }; +for (const kind of ['uint64', 'uuid']) { + const options = { prefix: 'rma', kind }; + const before = previous.createReferenceCodec(options), after = current.createReferenceCodec(options); + const baseline = createBech32Adapter(bech32m, options); + for (const s of scenarios(after, kind, bech32m)) { + countEqual(before.diagnose(s.input), after.diagnose(s.input)); + countEqual(baseline.diagnose(s.input), after.diagnose(s.input)); + jobs.push([() => before.diagnose(s.input), () => after.diagnose(s.input), () => baseline.diagnose(s.input)]); + } +} + +// Differential behavior, including the legacy API, malformed UTF-16, length +// limits and raw offsets. Seeded inputs are evidence, not production randomness. +let seed = 20261027; +const next = () => { seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; return seed >>> 0; }; +const alphabets = ['\0\t\r\n -_1qpzry9x8gf2tvdw0s3jn54khce6mua7lABCDEFGHIJKLMNOPQRSTUVWXYZ', '😀Ké\ud800\udc00']; +for (const options of [{ prefix: 'rma', kind: 'uint64' }, { prefix: 'v111', kind: 'uuid' }, { prefix: 'ticket', randomLength: 26 }]) { + const factory = options.kind ? 'createReferenceCodec' : 'createReferences'; + const a = previous[factory](options), b = current[factory](options); + const parse = options.kind ? 'decode' : 'parse', strict = parse + 'Canonical'; + for (let i = 0; i < 3000; i++) { + const bytes = createHash('sha256').update('optimization:' + i).digest(); + const hex = bytes.toString('hex').slice(0, 32); + const value = options.kind === 'uint64' ? bytes.readBigUInt64BE() : hex.replace(/^(.{8})(.{4})(.{4})(.{4})(.{12})$/, '$1-$2-$3-$4-$5'); + const id = options.kind ? a.encode(value) : bech32m.encode(options.prefix, Array.from(bytes.subarray(0, 26), n => n & 31)); + if (options.kind) countEqual(a.encode(value), b.encode(value)); + countEqual(a.format(id), b.format(id)); + const form = i % 3 === 0 ? ' \t' + a.format(id).toUpperCase() + '\r\n' : id; + const at = next() % (form.length + 1); + const alphabet = alphabets[i % 2]; + const ch = alphabet[next() % alphabet.length]; + const inputs = [form, form.slice(0, at) + ch + form.slice(at + 1), form.slice(0, at), form + ' '.repeat(i % 129)]; + for (const input of inputs) for (const method of ['diagnose', parse, strict]) countEqual(a[method](input), b[method](input)); + } + for (const input of [null, undefined, 1, 1n, {}, [], '\ud800', '\udc00', '😀K', ''.padEnd(129, 'q')]) { + for (const method of ['diagnose', parse, strict]) countEqual(a[method](input), b[method](input)); + } +} + +// A longer run of the same 28 cases. Rotate all three positions to avoid +// systematically giving one implementation the warmest or coolest CPU. +const iterations = 10000, rounds = 9, warmup = 1000; +const timings = [[], [], []]; +let consumed = 0; +function run(side, passes) { + for (let i = 0; i < passes; i++) for (const job of jobs) { + const result = job[side](); + consumed += result.ok ? result.id.length : result.error.length; + } +} +for (let side = 0; side < 3; side++) run(side, warmup); +for (let round = 0; round < rounds; round++) for (let offset = 0; offset < 3; offset++) { + const side = (round + offset) % 3; + const start = performance.now(); + run(side, iterations); + timings[side].push(Math.round(jobs.length * iterations * 1000 / (performance.now() - start))); +} +const summarize = samples => ({ ops_per_second: samples, median_ops_per_second: [...samples].sort((a, b) => a - b)[4] }); +const evidence = { + generated_at: new Date().toISOString(), + environment: { node: process.version, platform: process.platform, arch: process.arch, cpu: os.cpus()[0]?.model }, + previous: { revision, files: await sources(previousDist) }, + current: { files: await sources('dist') }, + baseline: { package: 'bech32@2.0.0', adapter_sha256: report.adapters.bech32.sha256 }, + differential: { comparisons, method: '3000 seeded values per uint64/UUID/legacy factory, generated encodings, formatting, canonical/displayed/mutated/truncated/overlong inputs, all parse modes and raw diagnostics; plus non-string and malformed UTF-16 inputs.' }, + timing: { method: `${rounds} rotated-order rounds in one process, ${warmup} warmup passes per implementation, ${iterations} passes per round over the same ${jobs.length} scenarios. Results consumed; no I/O.`, previous: summarize(timings[0]), current: summarize(timings[1]), bech32: summarize(timings[2]), consumed }, + limits: ['One machine and one input distribution; not a browser, database or user study.', 'The main comparison retains its original shorter two-way timing method; its medians can differ from this three-way run.', 'Full-root and codec-only sizes are separate in comparison.json. JS comments were removed from RefMint outputs only; source/declaration docs and all APIs are retained.'] +}; +await writeFile('docs/optimization.json', JSON.stringify(evidence, null, 2) + '\n'); +console.log(JSON.stringify({ comparisons, previous: evidence.timing.previous.median_ops_per_second, current: evidence.timing.current.median_ops_per_second, bech32: evidence.timing.bech32.median_ops_per_second }, null, 2)); diff --git a/scripts/compare.mjs b/scripts/compare.mjs new file mode 100644 index 0000000..caf9232 --- /dev/null +++ b/scripts/compare.mjs @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import { readFile, writeFile, readdir } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { gzipSync } from 'node:zlib'; +import os from 'node:os'; +import { posix } from 'node:path'; +import { bech32m } from 'bech32'; +import { createRefMintAdapter } from '../examples/comparison/refmint-adapter.mjs'; +import { createBech32Adapter } from '../examples/comparison/bech32-adapter.mjs'; +import { scenarios, lookup, sampleUUID } from '../examples/comparison/scenarios.mjs'; + +const checking = process.argv.includes('--check'); +const hash = bytes => createHash('sha256').update(bytes).digest('hex'); +const adapters = {}; +for (const name of ['refmint', 'bech32']) { + const file = `examples/comparison/${name}-adapter.mjs`; + const source = await readFile(file, 'utf8'); + adapters[name] = { file, code_lines: source.split('\n').filter(line => line.trim() && !line.trimStart().startsWith('//')).length, bytes: Buffer.byteLength(source), sha256: hash(source) }; +} +const runtime = (await readdir('dist')).filter(file => file.endsWith('.js')).sort().map(file => 'dist/' + file); +const loaded_code = {}; +// Follow actual static module imports; never hide the complete root-entry cost. +async function imports(file, found = new Set()) { + if (found.has(file)) return found; + found.add(file); + const source = await readFile(file, 'utf8'); + for (const [, dependency] of source.matchAll(/(?:import|export)[^;]*?from\s+['"]([^'"]+)['"]/g)) { + assert.ok(dependency.startsWith('.'), 'Unexpected runtime dependency'); + await imports(posix.normalize(posix.join(posix.dirname(file), dependency)), found); + } + return found; +} +const codecRuntime = [...await imports('dist/codec.js')].sort(); +for (const [name, files] of [['refmint', [...runtime, adapters.refmint.file]], ['refmint_codec', [...codecRuntime, adapters.refmint.file]], ['bech32', ['node_modules/bech32/dist/index.js', adapters.bech32.file]]]) { + const entries = []; + for (const file of files) { const bytes = await readFile(file); entries.push({ file, bytes: bytes.length, gzip_bytes: gzipSync(bytes).length, sha256: hash(bytes) }); } + loaded_code[name] = { files: entries, bytes: entries.reduce((n, f) => n + f.bytes, 0), summed_gzip_bytes: entries.reduce((n, f) => n + f.gzip_bytes, 0) }; +} +const results = [], lengths = [], jobs = []; +let mutationCount = 0; +const alphabet = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; +for (const kind of ['uint64', 'uuid']) { + const a = createRefMintAdapter({ prefix: 'rma', kind }); + const b = createBech32Adapter(bech32m, { prefix: 'rma', kind }); + const value = kind === 'uint64' ? 123456n : sampleUUID; + const id = a.encode(value); + assert.equal(b.encode(value), id); + lengths.push({ kind, original: String(value).length, refmint: id.length, bech32: id.length, printed: a.format(id).length }); + for (const s of scenarios(a, kind, bech32m)) { + const left = lookup(a, s.input, kind), right = lookup(b, s.input, kind); + assert.deepEqual(left, right, `${kind}: ${s.name}`); + assert.equal(left.status, s.status); + if (s.error) assert.equal(left.parsed.error, s.error); + results.push({ kind, scenario: s.name, outcome: s.status, error: s.error ?? null, refmint_queries: left.queries, bech32_queries: right.queries }); + jobs.push([() => a.diagnose(s.input), () => b.diagnose(s.input)]); + } + for (let i = 0; i < id.length; i++) for (const c of i < 3 ? 'abcdefghijklmnopqrstuvwxyz' : i < 5 ? '' : alphabet) { + if (id[i] === c) continue; + const changed = id.slice(0, i) + c + id.slice(i + 1); + assert.equal(a.diagnose(changed).ok, false); + assert.equal(b.diagnose(changed).ok, false); + mutationCount++; + } +} +const facts = { adapters, loaded_code, lengths, scenarios: results, + mutations: { tested_per_adapter: mutationCount, rejected_per_adapter: mutationCount, + method: 'All alternative lowercase letters in rma, and all alternative alphabet symbols in data/checksum of two fixed fixtures. Underscore/separator and normalization excluded.' } }; +if (checking) { + const recorded = JSON.parse(await readFile('docs/comparison.json', 'utf8')); + // Compressor output can differ between Node/zlib versions; verify source facts. + const portable = value => JSON.parse(JSON.stringify(value, (key, item) => ['gzip_bytes', 'summed_gzip_bytes'].includes(key) ? undefined : item)); + for (const key of Object.keys(facts)) assert.deepEqual(portable(recorded[key]), portable(facts[key]), `Refresh pnpm compare: ${key}`); + const optimization = JSON.parse(await readFile('docs/optimization.json', 'utf8')); + assert.deepEqual(optimization.current.files, loaded_code.refmint.files.filter(f => f.file.startsWith('dist/')).map(f => ({ file: posix.basename(f.file), bytes: f.bytes, sha256: f.sha256 })), 'Refresh pnpm compare:previous: runtime changed'); + assert.equal(optimization.baseline.adapter_sha256, adapters.bech32.sha256, 'Previous comparison baseline changed'); + console.log(`PASS: ${results.length} shared scenarios and ${mutationCount} mutations per adapter; recorded sources and facts match.`); +} else { + const iterations = 1000, rounds = 7; + const timings = [[], []]; + for (let i = 0; i < 200; i++) for (const job of jobs) { job[0](); job[1](); } + for (let round = 0; round < rounds; round++) for (const side of round % 2 ? [1, 0] : [0, 1]) { + const start = performance.now(); + for (let i = 0; i < iterations; i++) for (const job of jobs) job[side](); + timings[side].push(Math.round(jobs.length * iterations * 1000 / (performance.now() - start))); + } + const timing = samples => ({ ops_per_second: samples, median_ops_per_second: [...samples].sort((a, b) => a - b)[3] }); + const report = { + generated_at: new Date().toISOString(), + environment: { node: process.version, platform: process.platform, arch: process.arch, cpu: os.cpus()[0]?.model }, + baseline: 'bech32@2.0.0, Bech32m variant, independently implemented application adapter', + ...facts, + timing: { method: 'Seven alternating-order rounds, 200 warmup passes, 1000 passes per round through the same 28 cases. Diagnostic parsing only; no database, rendering or network.', refmint: timing(timings[0]), bech32: timing(timings[1]) }, + limits: [ + 'This compares two implementations of the RefMint v1 contract, not every alternative ID scheme.', + 'Adapter lines exclude blank/comment lines, shared UI, fixtures and lookup; RefMint moves contract logic into its library. Line count alone is not a maintainability measure.', + 'refmint counts all emitted JS plus its adapter; refmint_codec follows the actual codec-only imports used by the form. No minification/tree shaking. RefMint JS omits implementation comments; source and declaration comments are preserved. Bech32 uses its shipped JS unchanged. Shared UI and browser-only CJS wrapper are excluded. gzip files are compressed separately and summed.', + 'Both use the same standard checksum and produce the same codes. Both reject the tested mutations.', + 'Lookup counts describe an in-memory example, not measured production database savings.', + 'No human study, adoption evidence or general speed advantage. Timing is machine-specific and input-mix-dependent.', + 'Sequential IDs remain recoverable. The checksum does not provide authentication or authorization.' + ] + }; + await writeFile('docs/comparison.json', JSON.stringify(report, null, 2) + '\n'); + console.log(JSON.stringify({ adapters, lengths, scenarios: results.length, mutations: mutationCount, loaded_bytes: { refmint: loaded_code.refmint.bytes, bech32: loaded_code.bech32.bytes }, timing: report.timing }, null, 2)); +} diff --git a/scripts/demo-server.mjs b/scripts/demo-server.mjs index 77dcce4..cbb76d6 100644 --- a/scripts/demo-server.mjs +++ b/scripts/demo-server.mjs @@ -6,12 +6,28 @@ const files = new Map([ ['/dist/index.js', ['../dist/index.js', 'text/javascript; charset=utf-8']], ['/dist/bech32m.js', ['../dist/bech32m.js', 'text/javascript; charset=utf-8']] ]); +for (const name of ['codec', 'presentation']) files.set(`/dist/${name}.js`, [`../dist/${name}.js`, 'text/javascript; charset=utf-8']); +for (const name of ['index.html', 'app.mjs', 'style.css', 'bech32-adapter.mjs', 'refmint-adapter.mjs', 'scenarios.mjs']) { + const type = name.endsWith('.html') ? 'text/html' : name.endsWith('.css') ? 'text/css' : 'text/javascript'; + files.set('/comparison/' + (name === 'index.html' ? '' : name), [`../examples/comparison/${name}`, `${type}; charset=utf-8`]); +} +files.set('/docs/comparison.json', ['../docs/comparison.json', 'application/json; 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; } + // Local, installed dev dependency; no CDN, eval or runtime package dependency. + if (pathname === '/vendor/bech32.mjs' && req.method === 'GET') { + try { + const source = await readFile(new URL('../node_modules/bech32/dist/index.js', import.meta.url), 'utf8'); + const license = await readFile(new URL('../node_modules/bech32/LICENSE', import.meta.url), 'utf8'); + res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store' }); + res.end(`/* ${license.replaceAll('*/', '* /')} */\nconst exports = {};\n${source}\nexport const bech32m = exports.bech32m;\n`); + } catch { res.writeHead(500).end('Install development dependencies first.'); } + return; + } const entry = files.get(pathname); if (!entry || req.method !== 'GET') { res.writeHead(404).end(); return; } try { diff --git a/scripts/evidence.mjs b/scripts/evidence.mjs index 6b1435a..b799823 100644 --- a/scripts/evidence.mjs +++ b/scripts/evidence.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises'; import { gzipSync } from 'node:zlib'; import { createHash } from 'node:crypto'; import os from 'node:os'; @@ -35,7 +35,7 @@ function measure(operation, iterations = 20000) { return { iterations, rounds: 7, warmup: 2000, ops_per_second: samples.map(Math.round), median_ops_per_second: Math.round(sorted[3]) }; } const sizes = []; -for (const file of ['dist/index.js', 'dist/bech32m.js']) { +for (const file of (await readdir('dist')).filter(name => name.endsWith('.js')).sort().map(name => 'dist/' + name)) { const bytes = await readFile(file); sizes.push({ file, bytes: bytes.length, gzip_bytes: gzipSync(bytes).length, sha256: createHash('sha256').update(bytes).digest('hex') }); } @@ -46,7 +46,7 @@ const evidence = { runtime_dependencies: Object.keys(pkg.dependencies ?? {}).length, plan: refs.plan, mutations: { method: 'All 31 alternative alphabet characters at every payload/checksum position of four fixed fixtures; prefix ticket, payload lengths 8/14/26/64. Normalization changes excluded.', tested: mutations, rejected: mutations }, - size: { method: 'Emitted JavaScript, not minified. gzip sizes are compressed per file and then summed; not a bundled transfer measurement.', files: sizes, total_bytes: sizes.reduce((n, s) => n + s.bytes, 0), summed_gzip_bytes: sizes.reduce((n, s) => n + s.gzip_bytes, 0) }, + size: { method: 'All emitted JavaScript, implementation comments removed but not minified; source and declaration documentation retained. gzip sizes are compressed per file and then summed; not a bundled transfer measurement.', files: sizes, total_bytes: sizes.reduce((n, s) => n + s.bytes, 0), summed_gzip_bytes: sizes.reduce((n, s) => n + s.gzip_bytes, 0) }, timings: { method: 'Single process, seven rounds after warmup; no competitor comparison. Generation includes Web Crypto and checksum; parsing includes checksum and normalization.', generate: measure(() => refs.generate()), parse_displayed: measure(() => refs.parse(displayed)) }, collision_model: [8, 14, 26].map(length => ({ random_length: length, count: 1_000_000, birthday_approximation: collisionProbability(length, 1_000_000) })), limits: ['Finite mutation fixtures are not a proof of every checksum property.', 'No user study, adoption claim or measured reduction in support time.', 'Timing results are machine-specific; generation and parse do different work.', 'Collision calculations assume uniform independent sampling; enforce uniqueness in storage.'] diff --git a/scripts/serve.mjs b/scripts/serve.mjs index cac76a6..c67b69d 100644 --- a/scripts/serve.mjs +++ b/scripts/serve.mjs @@ -1,4 +1,6 @@ import { createDemoServer } from './demo-server.mjs'; const server = createDemoServer(); -server.listen(4173, '127.0.0.1', () => console.log('RefMint demo: http://127.0.0.1:4173')); +const port = Number(process.env.REFMINT_PORT ?? 4173); +if (!Number.isInteger(port) || port < 1 || port > 65535) throw new RangeError('REFMINT_PORT must be 1–65535'); +server.listen(port, '127.0.0.1', () => console.log(`RefMint demo: http://127.0.0.1:${port}`)); diff --git a/src/bech32m.ts b/src/bech32m.ts index 00e4bbd..6614a88 100644 --- a/src/bech32m.ts +++ b/src/bech32m.ts @@ -7,23 +7,21 @@ export const ALPHABET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; const GENERATORS = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; const CONSTANT = 0x2bc830a3; -/** Evaluate the 30-bit checksum polynomial on 5-bit symbols. */ -function residue(words: readonly number[]): number { - let state = 1; - for (const word of words) { - const top = state >>> 25; - state = ((state & 0x1ffffff) << 5) ^ word; - for (let bit = 0; bit < 5; bit++) { - if ((top >>> bit) & 1) state ^= GENERATORS[bit]!; - } - } - return state; +// The five high bits have only 32 combinations. Expand their XOR once, not +// five branches per input symbol. The BIP polynomial and wire format are unchanged. +const TOP = Array.from({ length: 32 }, (_, top) => + GENERATORS.reduce((sum, generator, bit) => sum ^ ((top >>> bit) & 1 ? generator : 0), 0)); +function step(state: number, word = 0): number { + return ((state & 0x1ffffff) << 5) ^ TOP[state >>> 25]! ^ word; } /** Protect both parts of every ASCII prefix character, as specified in BIP-173. */ -function expand(prefix: string): number[] { - const codes = Array.from(prefix, c => c.charCodeAt(0)); - return [...codes.map(c => c >>> 5), 0, ...codes.map(c => c & 31)]; +function prefixState(prefix: string): number { + let state = 1; + for (let i = 0; i < prefix.length; i++) state = step(state, prefix.charCodeAt(i) >>> 5); + state = step(state); + for (let i = 0; i < prefix.length; i++) state = step(state, prefix.charCodeAt(i) & 31); + return state; } /** Encode a lowercase printable ASCII prefix and payload symbols. */ @@ -33,9 +31,12 @@ export function encode(prefix: string, words: readonly number[]): string { words.some(w => !Number.isInteger(w) || w < 0 || w > 31)) { throw new RangeError('Invalid Bech32m input'); } - const check = residue([...expand(prefix), ...words, 0, 0, 0, 0, 0, 0]) ^ CONSTANT; - const suffix = Array.from({ length: 6 }, (_, i) => (check >>> (5 * (5 - i))) & 31); - return prefix + '1' + [...words, ...suffix].map(w => ALPHABET[w]!).join(''); + let state = prefixState(prefix), text = prefix + '1'; + for (const word of words) { state = step(state, word); text += ALPHABET[word]; } + for (let i = 0; i < 6; i++) state = step(state); + state ^= CONSTANT; + for (let shift = 25; shift >= 0; shift -= 5) text += ALPHABET[(state >>> shift) & 31]; + return text; } /** Decode generic Bech32m, accepting uniformly upper- or lowercase text. */ @@ -46,7 +47,13 @@ export function decode(text: string): { prefix: string; words: number[] } | unde const separator = canonical.lastIndexOf('1'); if (separator < 1 || canonical.length - separator - 1 < 6) return; const prefix = canonical.slice(0, separator); - const words = Array.from(canonical.slice(separator + 1), c => ALPHABET.indexOf(c)); - if (words.includes(-1) || residue([...expand(prefix), ...words]) !== CONSTANT) return; - return { prefix, words: words.slice(0, -6) }; + let state = prefixState(prefix); + const words: number[] = []; + for (let i = separator + 1; i < canonical.length; i++) { + const word = ALPHABET.indexOf(canonical[i]!); + if (word < 0) return; + state = step(state, word); + if (i < canonical.length - 6) words.push(word); + } + if (state === CONSTANT) return { prefix, words }; } diff --git a/src/codec.ts b/src/codec.ts new file mode 100644 index 0000000..2e8a22e --- /dev/null +++ b/src/codec.ts @@ -0,0 +1,117 @@ +import { decode, encode } from './bech32m.js'; +import { failure, formatBody, readPresentation, type Failure } from './presentation.js'; +import type { ParseError } from './index.js'; + +export type ReferenceKind = 'uint64' | 'uuid'; +export type ReferenceValue = K extends 'uint64' ? bigint : string; +declare const encodedReferenceBrand: unique symbol; +export type EncodedReference

= string & { + readonly [encodedReferenceBrand]: readonly [P, K, 1]; +}; +export interface ReferenceCodecOptions

{ + readonly prefix: P; + readonly kind: K; + readonly version?: 1; +} +export type DecodeError = ParseError | 'version' | 'kind' | 'overflow'; +type Decoded

= { + readonly ok: true; + readonly id: EncodedReference; + readonly value: ReferenceValue; + readonly normalized: boolean; +}; +export type DecodeResult

= + Decoded | { readonly ok: false; readonly error: DecodeError }; +export type DecodeDiagnostic

= Decoded | Failure; +export interface ReferenceCodec

{ + readonly prefix: P; + readonly kind: K; + readonly version: 1; + encode(value: ReferenceValue): EncodedReference; + decode(input: unknown): DecodeResult; + decodeCanonical(input: unknown): DecodeResult; + diagnose(input: unknown): DecodeDiagnostic; + format(id: EncodedReference): string; +} + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +/** Reversible public-ID representation. No random allocation, encryption or authorization. */ +export function createReferenceCodec( + options: ReferenceCodecOptions +): ReferenceCodec { + if (options === null || typeof options !== 'object') throw new TypeError('Options must be an object'); + for (const key of Object.keys(options)) { + if (!['prefix', 'kind', 'version'].includes(key)) throw new TypeError(`Unknown option: ${key}`); + } + const { prefix, kind, version = 1 } = options; + if (typeof prefix !== 'string' || !/^[a-z][a-z0-9]{0,15}$/.test(prefix)) { + throw new RangeError('prefix must be 1–16 lowercase ASCII letters/digits, starting with a letter'); + } + if (kind !== 'uint64' && kind !== 'uuid') throw new RangeError('kind must be uint64 or uuid'); + if (version !== 1) throw new RangeError('Only format version 1 is supported'); + const kindWord = kind === 'uint64' ? 0 : 1; + const bits = kind === 'uint64' ? 64n : 128n; + const limit = 1n << bits; + // The underscore is forbidden by the alpha.1 random-reference grammar. + const wirePrefix = prefix + '_'; + + function inspect(input: unknown, strict: boolean): DecodeDiagnostic { + const presented = readPresentation(input, /^[a-z][a-z0-9]{0,15}_$/); + if (!presented.ok) return presented; + const { canonical } = presented; + const decoded = decode(canonical); + if (!decoded) return failure('checksum'); + if (decoded.prefix !== wirePrefix) return failure('prefix', { expected: prefix, actual: decoded.prefix.slice(0, -1) }); + const { words } = decoded; + if (words.length < 2) return failure('length', { expected: 'at least 2', actual: words.length, unit: 'data_symbols' }); + if (words[0] !== 1) return failure('version', { expected: 1, actual: words[0]! }); + if (words[1] !== kindWord) return failure('kind', { expected: kind, actual: words[1] === 0 ? 'uint64' : words[1] === 1 ? 'uuid' : words[1]! }); + const length = words.length - 2; + if (kind === 'uuid' ? length !== 26 : length < 1 || length > 13) { + return failure('length', { expected: kind === 'uuid' ? 26 : '1–13', actual: length, unit: 'payload_symbols' }); + } + let value = 0n; + // Ten symbols fit exactly in a JS number (50 bits). Convert chunks, not + // every symbol, to BigInt; the full uint64/UUID never passes through Number. + for (let i = 2; i < words.length;) { + const end = Math.min(i + 10, words.length), width = BigInt((end - i) * 5); + let chunk = 0; + while (i < end) chunk = chunk * 32 + words[i++]!; + value = (value << width) | BigInt(chunk); + } + if (value >= limit) return failure('overflow'); + if (kind === 'uint64' && length > 1 && words[2] === 0) return failure('non_canonical'); + if (strict && canonical !== input) return failure('non_canonical'); + const original = kind === 'uint64' ? value : value.toString(16).padStart(32, '0').replace(/^(.{8})(.{4})(.{4})(.{4})(.{12})$/, '$1-$2-$3-$4-$5'); + return { ok: true, id: canonical as EncodedReference, value: original as ReferenceValue, normalized: canonical !== input }; + } + function parse(input: unknown, strict: boolean): DecodeResult { + const result = inspect(input, strict); + return result.ok ? result : { ok: false, error: result.error }; + } + return Object.freeze({ + prefix, kind, version: 1, + encode(input: ReferenceValue): EncodedReference { + let value: bigint; + if (kind === 'uuid') { + if (typeof input !== 'string' || !UUID.test(input)) throw new TypeError('UUID must be canonical lowercase hexadecimal with hyphens'); + value = BigInt('0x' + input.replaceAll('-', '')); + } else { + if (typeof input !== 'bigint') throw new TypeError('uint64 requires bigint; numbers can lose precision'); + value = input; + } + if (value < 0n || value >= limit) throw new RangeError('Value is outside the codec range'); + const words = Array.from(value.toString(32).padStart(kind === 'uuid' ? 26 : 1, '0'), c => parseInt(c, 32)); + return encode(wirePrefix, [1, kindWord, ...words]) as EncodedReference; + }, + decode: (input: unknown) => parse(input, false), + decodeCanonical: (input: unknown) => parse(input, true), + diagnose: (input: unknown) => inspect(input, false), + format(id: EncodedReference): string { + const result = parse(id, true); + if (!result.ok) throw new TypeError(`Cannot format reference: ${result.error}`); + return formatBody(id); + } + }); +} diff --git a/src/index.ts b/src/index.ts index e40aef3..29b27ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,8 @@ -import { ALPHABET, decode, encode } from './bech32m.js'; +import { decode, encode } from './bech32m.js'; +import { failure, formatBody, readPresentation, type Failure } from './presentation.js'; +export { createReferenceCodec } from './codec.js'; +export type { ReferenceCodecOptions, ReferenceCodec, ReferenceKind, ReferenceValue, EncodedReference, DecodeError, DecodeResult, DecodeDiagnostic } from './codec.js'; +export type { ValidationDetails } from './presentation.js'; declare const referenceBrand: unique symbol; /** A validated canonical reference. Branding prevents accidental cross-prefix use. */ @@ -27,6 +31,7 @@ export type ParseError = 'type' | 'too_long' | 'format' | 'case' | 'character' | export type ParseResult

= | { readonly ok: true; readonly id: Reference

; readonly normalized: boolean } | { readonly ok: false; readonly error: ParseError }; +export type ParseDiagnostic

= Extract, { ok: true }> | Failure; export interface References

{ readonly prefix: P; @@ -37,6 +42,8 @@ export interface References

{ parse(input: unknown): ParseResult

; /** Require the exact lowercase, ungrouped storage representation. */ parseCanonical(input: unknown): ParseResult

; + /** Same human-input rules, with bounded diagnostics; never suggests a corrected ID. */ + diagnose(input: unknown): ParseDiagnostic

; /** Format a validated ID in groups of four after the separator. */ format(id: Reference

): string; } @@ -128,35 +135,21 @@ function configure

(options: ReferenceOptions

): { prefix: P; export function createReferences(options: ReferenceOptions

): References

{ const { prefix, plan } = configure(options); - function parse(input: unknown, strict: boolean): ParseResult

{ - const fail = (error: ParseError): ParseResult

=> ({ ok: false, error }); - if (typeof input !== 'string') return fail('type'); - // Bound work before trimming, regex matching or allocating character arrays. - if (input.length > 128) return fail('too_long'); - // Unicode case folding can turn K into ASCII k. Reject it before folding. - if (/[^\x00-\x7f]/.test(input)) return fail('character'); - let text = input.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, ''); - if (text !== text.toLowerCase() && text !== text.toUpperCase()) return fail('case'); - text = text.toLowerCase(); - const separator = text.lastIndexOf('1'); - if (separator < 1 || !/^[a-z][a-z0-9]{0,15}$/.test(text.slice(0, separator))) return fail('format'); - let body = text.slice(separator + 1); - if (body.includes('-')) { - const groups = body.split('-'); - if (groups.some((group, i) => i < groups.length - 1 ? group.length !== 4 : group.length < 1 || group.length > 4)) { - return fail('format'); - } - body = groups.join(''); - } - if (Array.from(body).some(c => !ALPHABET.includes(c))) return fail('character'); - text = text.slice(0, separator + 1) + body; + function inspect(input: unknown, strict: boolean): ParseDiagnostic

{ + const presented = readPresentation(input, /^[a-z][a-z0-9]{0,15}$/); + if (!presented.ok) return presented; + const text = presented.canonical; const decoded = decode(text); - if (!decoded) return fail('checksum'); - if (decoded.prefix !== prefix) return fail('prefix'); - if (decoded.words.length !== plan.randomLength) return fail('length'); - if (strict && text !== input) return fail('non_canonical'); + if (!decoded) return failure('checksum'); + if (decoded.prefix !== prefix) return failure('prefix', { expected: prefix, actual: decoded.prefix }); + if (decoded.words.length !== plan.randomLength) return failure('length', { expected: plan.randomLength, actual: decoded.words.length, unit: 'payload_symbols' }); + if (strict && text !== input) return failure('non_canonical'); return { ok: true, id: text as Reference

, normalized: text !== input }; } + function parse(input: unknown, strict: boolean): ParseResult

{ + const result = inspect(input, strict); + return result.ok ? result : { ok: false, error: result.error }; + } return Object.freeze({ prefix, plan, @@ -170,11 +163,11 @@ export function createReferences(options: ReferenceOptio }, parse: (input: unknown) => parse(input, false), parseCanonical: (input: unknown) => parse(input, true), + diagnose: (input: unknown) => inspect(input, false), format(id: Reference

): string { const parsed = parse(id, true); if (!parsed.ok) throw new TypeError(`Cannot format reference: ${parsed.error}`); - const start = prefix.length + 1; - return id.slice(0, start) + id.slice(start).match(/.{1,4}/g)!.join('-'); + return formatBody(id); } }); } diff --git a/src/presentation.ts b/src/presentation.ts new file mode 100644 index 0000000..b2c79b2 --- /dev/null +++ b/src/presentation.ts @@ -0,0 +1,54 @@ +/** Positions are zero-based UTF-16 offsets in the original, unmodified input. */ +export interface ValidationDetails { + readonly positions?: readonly number[]; + readonly expected?: string | number; + readonly actual?: string | number; + readonly unit?: 'input_code_units' | 'data_symbols' | 'payload_symbols'; +} + +type InputError = 'type' | 'too_long' | 'format' | 'case' | 'character'; +export type Failure = { readonly ok: false; readonly error: E; readonly details: ValidationDetails }; +export function failure(error: E, details: ValidationDetails = {}): Failure { + return { ok: false, error, details }; +} + +/** Normalize presentation only. Never guess a replacement character or checksum. */ +export function readPresentation(input: unknown, prefixPattern: RegExp): + { readonly ok: true; readonly canonical: string } | Failure { + if (typeof input !== 'string') return failure('type'); + if (input.length > 128) return failure('too_long', { expected: 128, actual: input.length, unit: 'input_code_units' }); + if (/[^\x00-\x7f]/.test(input)) { + const positions: number[] = []; + for (let i = 0; i < input.length; i++) { + const cp = input.codePointAt(i)!; + if (cp > 127) positions.push(i); + if (cp > 0xffff) i++; + } + return failure('character', { positions }); + } + let text = input.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, ''); + const lower = text.toLowerCase(); + if (text !== lower && text !== text.toUpperCase()) return failure('case'); + text = lower; + const separator = text.lastIndexOf('1'); + if (separator < 1 || !prefixPattern.test(text.slice(0, separator))) return failure('format'); + const body = text.slice(separator + 1); + if (body.includes('-')) { + const groups = body.split('-'); + if (groups.some((group, i) => i < groups.length - 1 ? group.length !== 4 : group.length < 1 || group.length > 4)) { + return failure('format'); + } + } + const invalid = /[^qpzry9x8gf2tvdw0s3jn54khce6mua7l-]/g; + if (invalid.test(body)) { + const start = input.match(/^[ \t\r\n]*/)![0].length + separator + 1; + invalid.lastIndex = 0; + return failure('character', { positions: Array.from(body.matchAll(invalid), m => start + m.index) }); + } + return { ok: true, canonical: text.replaceAll('-', '') }; +} + +export function formatBody(id: string): string { + const start = id.lastIndexOf('1') + 1; + return id.slice(0, start) + id.slice(start).match(/.{1,4}/g)!.join('-'); +} diff --git a/tests/browser/comparison.spec.mjs b/tests/browser/comparison.spec.mjs new file mode 100644 index 0000000..5a0faf6 --- /dev/null +++ b/tests/browser/comparison.spec.mjs @@ -0,0 +1,50 @@ +import { test, expect } from '@playwright/test'; + +test('both existing-ID forms find the same record and stop invalid input before lookup', async ({ page }) => { + const errors = []; + const scripts = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('request', request => { if (request.resourceType() === 'script') scripts.push(new URL(request.url()).pathname); }); + await page.goto('/comparison/'); + for (const kind of ['uint64', 'uuid']) { + await page.getByLabel('Existing ID type').selectOption(kind); + await page.getByRole('button', { name: 'Use printed reference' }).click(); + for (const name of ['refmint', 'bech32']) { + await expect(page.locator(`#${name}-result`)).toHaveAttribute('data-state', 'found'); + await expect(page.locator(`#${name}-queries`)).toContainText('submission: 1'); + } + await page.getByRole('button', { name: 'Introduce a typo' }).click(); + for (const name of ['refmint', 'bech32']) { + await expect(page.locator(`#${name}-result`)).toContainText('typo check failed'); + await expect(page.locator(`#${name}-queries`)).toContainText('submission: 0'); + } + await page.getByRole('button', { name: 'Try invalid character' }).click(); + await expect(page.locator('#refmint-result')).toContainText('position 6'); + await expect(page.locator('#bech32-result')).toHaveText(await page.locator('#refmint-result').textContent()); + await page.getByRole('button', { name: 'Valid but unknown' }).click(); + await expect(page.locator('#refmint-result')).toHaveAttribute('data-state', 'unknown'); + await expect(page.locator('#bech32-result')).toHaveAttribute('data-state', 'unknown'); + await page.getByRole('button', { name: 'Run all scenarios' }).click(); + await expect(page.locator('#matrix-status')).toContainText('14/14'); + await expect(page.locator('#matrix tr')).toHaveCount(14); + } + expect(errors).toEqual([]); + expect(scripts).toContain('/dist/codec.js'); + expect(scripts).not.toContain('/dist/index.js'); +}); + +test('comparison accepts manual input, reports raw positions and fits a phone viewport', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto('/comparison/'); + const printed = await page.locator('#reference').textContent(); + const input = page.getByLabel('Return reference', { exact: true }); + await input.fill(' ' + printed.replace('R', 'K')); + await page.getByRole('button', { name: 'Check both forms' }).click(); + for (const name of ['refmint', 'bech32']) await expect(page.locator(`#${name}-result`)).toContainText('position 3'); + await input.fill(printed); + await page.getByRole('button', { name: 'Check both forms' }).click(); + await expect(page.locator('#refmint-result')).toHaveAttribute('data-state', 'found'); + await page.getByLabel('Existing ID type').selectOption('uuid'); + await page.getByRole('button', { name: 'Run all scenarios' }).click(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); +}); diff --git a/tests/codec.test.mjs b/tests/codec.test.mjs new file mode 100644 index 0000000..59e1d0f --- /dev/null +++ b/tests/codec.test.mjs @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { bech32m } from 'bech32'; +import { createReferenceCodec, createReferences } from '../dist/index.js'; +import { createBech32Adapter } from '../examples/comparison/bech32-adapter.mjs'; +import { scenarios, lookup } from '../examples/comparison/scenarios.mjs'; + +const uint = createReferenceCodec({ prefix: 'rma', kind: 'uint64' }); +const uuid = createReferenceCodec({ prefix: 'rma', kind: 'uuid' }); + +test('frozen format vectors survive upgrades and legacy references remain disjoint', async () => { + const vectors = JSON.parse(await readFile(new URL('./fixtures/formats-v1.json', import.meta.url))); + for (const v of vectors.codec) { + const codec = createReferenceCodec({ prefix: v.prefix, kind: v.kind, version: 1 }); + const value = v.kind === 'uint64' ? BigInt(v.value) : v.value; + assert.equal(codec.encode(value), v.reference); + assert.deepEqual(codec.decodeCanonical(v.reference), { ok: true, id: v.reference, value, normalized: false }); + assert.equal(createReferences({ prefix: v.prefix }).parse(v.reference).ok, false); + } + for (const v of vectors.legacy) { + const old = createReferences({ prefix: v.prefix, randomLength: v.randomLength }); + assert.equal(old.parseCanonical(v.reference).id, v.reference); + for (const kind of ['uuid', 'uint64']) assert.equal(createReferenceCodec({ prefix: v.prefix, kind }).decode(v.reference).ok, false); + } +}); + +test('uint64 exact boundaries and UUID bits round trip through an independent oracle', () => { + const integers = [0n, 1n, (1n << 64n) - 1n]; + for (let bit = 1n; bit < 64n; bit++) integers.push((1n << bit) - 1n, 1n << bit, (1n << bit) + 1n); + const uuids = ['00000000-0000-0000-0000-000000000000', 'ffffffff-ffff-ffff-ffff-ffffffffffff']; + for (let i = 0; i < 1000; i++) { + const bytes = createHash('sha256').update('refmint-v1:' + i).digest(); + integers.push(bytes.readBigUInt64BE()); + uuids.push(bytes.toString('hex').slice(0, 32).replace(/^(.{8})(.{4})(.{4})(.{4})(.{12})$/, '$1-$2-$3-$4-$5')); + } + for (const [c, values] of [[uint, integers], [uuid, uuids]]) for (const value of values) { + const id = c.encode(value); + assert.equal(c.decode(id).value, value); + assert.equal(c.decode(c.format(id).toUpperCase()).value, value); + const d = bech32m.decode(id); + assert.equal(d.prefix, 'rma_'); + assert.equal(d.words[0], 1); + const numeric = d.words.slice(2).reduce((n, word) => n * 32n + BigInt(word), 0n); + assert.equal(numeric, typeof value === 'bigint' ? value : BigInt('0x' + value.replaceAll('-', ''))); + assert.equal(bech32m.encode(d.prefix, d.words), id); + } +}); + +test('encoding never truncates, rounds a number, changes UUID case or needs randomness', t => { + t.mock.getter(globalThis, 'crypto', () => undefined); + for (const value of [1, Number.MAX_SAFE_INTEGER + 1, '1', -1n, 1n << 64n, null]) assert.throws(() => uint.encode(value)); + for (const value of [1n, null, 'abc', '550E8400-e29b-41d4-a716-446655440000']) assert.throws(() => uuid.encode(value)); + assert.equal(uint.decode(uint.encode(9007199254740993n)).value, 9007199254740993n); +}); + +test('UUID conversion is exact across every bit boundary, including 50-bit chunks', () => { + for (let bit = 1n; bit < 128n; bit++) for (const n of [(1n << bit) - 1n, 1n << bit, (1n << bit) + 1n]) { + const value = n.toString(16).padStart(32, '0').replace(/^(.{8})(.{4})(.{4})(.{4})(.{12})$/, '$1-$2-$3-$4-$5'); + const id = uuid.encode(value); + assert.equal(uuid.decode(id).value, value); + assert.equal(bech32m.decode(id).words.slice(2).reduce((v, word) => v * 32n + BigInt(word), 0n), n); + } +}); + +test('all ASCII substitutions retain diagnostics and input-error precedence', () => { + for (const kind of ['uint64', 'uuid']) { + const a = createReferenceCodec({ prefix: 'rma', kind }); + const b = createBech32Adapter(bech32m, { prefix: 'rma', kind }); + const id = a.encode(kind === 'uint64' ? 123456n : 'ffffffff-ffff-ffff-ffff-ffffffffffff'); + for (const text of [id, a.format(id), ' \t' + a.format(id).toUpperCase() + '\r\n']) { + for (let at = 0; at < text.length; at++) for (let code = 0; code < 128; code++) { + const input = text.slice(0, at) + String.fromCharCode(code) + text.slice(at + 1); + assert.deepEqual(a.diagnose(input), b.diagnose(input), JSON.stringify(input)); + } + } + } +}); + +test('valid checksums cannot bypass version, kind, length, range or canonicality', () => { + const wrap = data => bech32m.encode('rma_', data); + const cases = [ + [[], 'length'], [[1], 'length'], [[2, 0, 1], 'version'], [[1, 9, 1], 'kind'], + [[1, 0], 'length'], [[1, 0, ...Array(14).fill(1)], 'length'], + [[1, 0, 16, ...Array(12).fill(0)], 'overflow'], [[1, 0, 0, 1], 'non_canonical'] + ]; + for (const [data, error] of cases) assert.deepEqual(uint.decode(wrap(data)), { ok: false, error }); + assert.equal(uuid.decode(wrap([1, 1, 8, ...Array(25).fill(0)])).error, 'overflow'); + assert.equal(uuid.decode(wrap([1, 1, ...Array(25).fill(0)])).error, 'length'); + const id = uint.encode(42n); + assert.equal(uint.decodeCanonical(uint.format(id)).error, 'non_canonical'); + assert.throws(() => uint.format(uuid.encode('00000000-0000-0000-0000-000000000000'))); + assert.equal(uint.decode(id.replace('rma', 'doc')).error, 'checksum'); +}); + +test('new configuration is immutable and reads dynamic options once', () => { + const reads = { prefix: 0, kind: 0, version: 0 }; + const c = createReferenceCodec({ + get prefix() { reads.prefix++; return reads.prefix === 1 ? 'rma' : 'doc'; }, + get kind() { reads.kind++; return 'uint64'; }, + get version() { reads.version++; return 1; } + }); + assert.deepEqual(reads, { prefix: 1, kind: 1, version: 1 }); + assert.ok(Object.isFrozen(c)); + assert.equal(c.decode(c.encode(1n)).value, 1n); + for (const opts of [null, {}, { prefix: 'rma', kind: 'number' }, { prefix: 'rma_', kind: 'uuid' }, + { prefix: 'rma', kind: 'uuid', version: 2 }, { prefix: 'rma', kind: 'uuid', version: null }, + { prefix: 'rma', kind: 'uuid', randomLength: 26 }]) assert.throws(() => createReferenceCodec(opts)); + const long = createReferenceCodec({ prefix: 'v111111111111111', kind: 'uuid' }); + const id = long.encode('ffffffff-ffff-ffff-ffff-ffffffffffff'); + assert.equal(long.decode(long.format(id)).id, id); +}); + +test('both form adapters agree on all scenarios, including lookup counts and diagnostics', () => { + for (const kind of ['uint64', 'uuid']) { + const c = createReferenceCodec({ prefix: 'rma', kind }); + const native = createBech32Adapter(bech32m, { prefix: 'rma', kind }); + for (const s of scenarios(c, kind, bech32m)) { + const result = lookup(c, s.input, kind); + assert.equal(result.status, s.status, s.name); + if (s.error) assert.equal(result.parsed.error, s.error, s.name); + assert.deepEqual(lookup(native, s.input, kind), result, s.name); + if (result.parsed.ok) assert.equal(native.encode(result.parsed.value), result.parsed.id); + } + } +}); + +test('all selected single-symbol changes and distinct adjacent swaps are rejected by both adapters', () => { + const alphabet = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; + for (const kind of ['uint64', 'uuid']) { + const c = createReferenceCodec({ prefix: 'rma', kind }); + const native = createBech32Adapter(bech32m, { prefix: 'rma', kind }); + const id = scenarios(c, kind, bech32m)[0].input; + for (let i = 0; i < id.length; i++) for (const ch of i < 3 ? 'abcdefghijklmnopqrstuvwxyz' : i < 5 ? '' : alphabet) { + if (id[i] === ch) continue; + const changed = id.slice(0, i) + ch + id.slice(i + 1); + assert.equal(c.decode(changed).ok, false); + assert.equal(native.diagnose(changed).ok, false); + } + for (let i = 0; i < id.length - 1; i++) { + if (id[i] === id[i + 1]) continue; + const changed = id.slice(0, i) + id[i + 1] + id[i] + id.slice(i + 2); + assert.equal(c.decode(changed).ok, false); + assert.equal(native.diagnose(changed).ok, false); + } + } +}); diff --git a/tests/demo/server.test.mjs b/tests/demo/server.test.mjs index 262536c..d3bfec7 100644 --- a/tests/demo/server.test.mjs +++ b/tests/demo/server.test.mjs @@ -34,7 +34,7 @@ test('malformed URL returns 400 and the next request still succeeds', async () = }); test('only demo assets are served, with correct types and no caching', async () => { - for (const [path, type] of [['/?example=1', 'text/html'], ['/dist/index.js', 'text/javascript'], ['/dist/bech32m.js', 'text/javascript']]) { + for (const [path, type] of [['/?example=1', 'text/html'], ['/dist/index.js', 'text/javascript'], ['/dist/bech32m.js', 'text/javascript'], ['/dist/codec.js', 'text/javascript'], ['/dist/presentation.js', 'text/javascript'], ['/comparison/', 'text/html'], ['/comparison/app.mjs', 'text/javascript'], ['/comparison/style.css', 'text/css'], ['/vendor/bech32.mjs', 'text/javascript'], ['/docs/comparison.json', 'application/json']]) { const response = await get(path); assert.equal(response.status, 200); assert.ok(response.headers['content-type'].startsWith(type)); diff --git a/tests/diagnostics.test.mjs b/tests/diagnostics.test.mjs new file mode 100644 index 0000000..e3bc543 --- /dev/null +++ b/tests/diagnostics.test.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { bech32m } from 'bech32'; +import { createReferences, createReferenceCodec } from '../dist/index.js'; + +test('diagnostics preserve raw offsets through whitespace, case and grouping', () => { + for (const refs of [createReferences({ prefix: 'rma', randomLength: 8 }), createReferenceCodec({ prefix: 'rma', kind: 'uint64' })]) { + const id = refs.generate ? bech32m.encode('rma', Array(8).fill(0)) : refs.encode(123456n); + const display = ' \t' + refs.format(id).toUpperCase() + '\r\n'; + const i = display.indexOf('-') + 1; + const invalid = display.slice(0, i) + 'B' + display.slice(i + 1); + assert.deepEqual(refs.diagnose(invalid), { ok: false, error: 'character', details: { positions: [i] } }); + assert.deepEqual(refs.diagnose('😀' + id + 'K').details.positions, [0, id.length + 2]); + assert.equal(refs.diagnose(display).id, id); + assert.deepEqual(refs.diagnose('a'.repeat(129)), { ok: false, error: 'too_long', details: { expected: 128, actual: 129, unit: 'input_code_units' } }); + } +}); + +test('length and namespace diagnostics only follow a valid checksum; no guessed correction', () => { + const refs = createReferences({ prefix: 'rma', randomLength: 8 }); + assert.deepEqual(refs.diagnose(bech32m.encode('rma', Array(9).fill(0))), { + ok: false, error: 'length', details: { expected: 8, actual: 9, unit: 'payload_symbols' } + }); + assert.deepEqual(refs.diagnose(bech32m.encode('doc', Array(8).fill(0))), { + ok: false, error: 'prefix', details: { expected: 'rma', actual: 'doc' } + }); + const id = bech32m.encode('rma', Array(8).fill(0)); + const wrong = id.slice(0, -1) + (id.endsWith('q') ? 'p' : 'q'); + assert.deepEqual(refs.diagnose(wrong), { ok: false, error: 'checksum', details: {} }); + assert.deepEqual(refs.parse(wrong), { ok: false, error: 'checksum' }); +}); diff --git a/tests/fixtures/formats-v1.json b/tests/fixtures/formats-v1.json new file mode 100644 index 0000000..0e13093 --- /dev/null +++ b/tests/fixtures/formats-v1.json @@ -0,0 +1,142 @@ +{ + "description": "Frozen compatibility fixtures. Generated once using independent bech32@2.0.0 and radix-32 conversion; never regenerate to make an incompatible change pass.", + "codec": [ + { + "prefix": "rma", + "kind": "uint64", + "value": "0", + "reference": "rma_1pqqgmlxhp" + }, + { + "prefix": "rma", + "kind": "uint64", + "value": "1", + "reference": "rma_1pqp4dtn2n" + }, + { + "prefix": "rma", + "kind": "uint64", + "value": "31", + "reference": "rma_1pqlzhyza2" + }, + { + "prefix": "rma", + "kind": "uint64", + "value": "32", + "reference": "rma_1pqpqy80r4r" + }, + { + "prefix": "rma", + "kind": "uint64", + "value": "123456", + "reference": "rma_1pqrcjqv62em2" + }, + { + "prefix": "rma", + "kind": "uint64", + "value": "9007199254740993", + "reference": "rma_1pqgqqqqqqqqqp0tg526" + }, + { + "prefix": "rma", + "kind": "uint64", + "value": "18446744073709551615", + "reference": "rma_1pq0lllllllllllllkzhy6" + }, + { + "prefix": "rma", + "kind": "uuid", + "value": "00000000-0000-0000-0000-000000000000", + "reference": "rma_1ppqqqqqqqqqqqqqqqqqqqqqqqqqql4xtwn" + }, + { + "prefix": "rma", + "kind": "uuid", + "value": "550e8400-e29b-41d4-a716-446655440000", + "reference": "rma_1ppz4p6zqpc5mg822w9jyve25gqqqrmjq9w" + }, + { + "prefix": "rma", + "kind": "uuid", + "value": "ffffffff-ffff-ffff-ffff-ffffffffffff", + "reference": "rma_1pp8lllllllllllllllllllllllll2x7m4j" + }, + { + "prefix": "v11", + "kind": "uint64", + "value": "0", + "reference": "v11_1pqqzjhzk2" + }, + { + "prefix": "v11", + "kind": "uint64", + "value": "1", + "reference": "v11_1pqplyrhtc" + }, + { + "prefix": "v11", + "kind": "uint64", + "value": "31", + "reference": "v11_1pqlg7vxup" + }, + { + "prefix": "v11", + "kind": "uint64", + "value": "32", + "reference": "v11_1pqpqq7wdnn" + }, + { + "prefix": "v11", + "kind": "uint64", + "value": "123456", + "reference": "v11_1pqrcjqwcpmgw" + }, + { + "prefix": "v11", + "kind": "uint64", + "value": "9007199254740993", + "reference": "v11_1pqgqqqqqqqqqpmhdqhv" + }, + { + "prefix": "v11", + "kind": "uint64", + "value": "18446744073709551615", + "reference": "v11_1pq0lllllllllllld8z73d" + }, + { + "prefix": "v11", + "kind": "uuid", + "value": "00000000-0000-0000-0000-000000000000", + "reference": "v11_1ppqqqqqqqqqqqqqqqqqqqqqqqqqq46m0l8" + }, + { + "prefix": "v11", + "kind": "uuid", + "value": "550e8400-e29b-41d4-a716-446655440000", + "reference": "v11_1ppz4p6zqpc5mg822w9jyve25gqqqf50y56" + }, + { + "prefix": "v11", + "kind": "uuid", + "value": "ffffffff-ffff-ffff-ffff-ffffffffffff", + "reference": "v11_1pp8lllllllllllllllllllllllllqfrlyx" + } + ], + "legacy": [ + { + "prefix": "ticket", + "randomLength": 14, + "reference": "ticket1qpzry9x8gf2tvdr5gua6" + }, + { + "prefix": "rma", + "randomLength": 8, + "reference": "rma1qpzry9x8lrz9yy" + }, + { + "prefix": "v11", + "randomLength": 64, + "reference": "v111qpzry9x8gf2tvdw0s3jn54khce6mua7lqpzry9x8gf2tvdw0s3jn54khce6mua7lzm44gz" + } + ] +} diff --git a/tests/types.ts b/tests/types.ts index 5f989a8..8572e4d 100644 --- a/tests/types.ts +++ b/tests/types.ts @@ -14,3 +14,21 @@ createReferences({ prefix: 'doc', randomLength: 12, expectedCount: 100, maxColli const result = docs.parse('user input'); if (result.ok) docs.format(result.id); else { const error: string = result.error; void error; } + +import { createReferenceCodec, type EncodedReference } from '../dist/index.js'; +const numbers = createReferenceCodec({ prefix: 'rma', kind: 'uint64', version: 1 }); +const uuids = createReferenceCodec({ prefix: 'rma', kind: 'uuid' }); +const encoded: EncodedReference<'rma', 'uint64'> = numbers.encode(42n); +// @ts-expect-error No floating-point numbers for uint64. +numbers.encode(42); +// @ts-expect-error Kind is part of the reference brand. +uuids.format(encoded); +// @ts-expect-error Random IDs are not encoded existing IDs. +numbers.format(createReferences({ prefix: 'rma' }).generate()); +// @ts-expect-error Version 2 is not supported. +createReferenceCodec({ prefix: 'rma', kind: 'uuid', version: 2 }); +const decoded = numbers.decode('input'); +if (decoded.ok) { const original: bigint = decoded.value; void original; } +const diagnostic = uuids.diagnose('input'); +if (diagnostic.ok) { const original: string = diagnostic.value; void original; } +else { const positions: readonly number[] | undefined = diagnostic.details.positions; void positions; }