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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Test local preflight

on:
pull_request:
branches: [main]
push:
branches: [main]

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: node --test tests/*.test.mjs
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@
This repository hosts the LedgerParity documentation site at
<https://ledgerparity.github.io/>.

Static, build-free HTML/CSS (see [using-the-site](https://ledgerparity.github.io/)).
Static, build-free HTML/CSS and JavaScript (see [using-the-site](https://ledgerparity.github.io/)). The [local preflight](https://ledgerparity.github.io/preflight.html) checks canonical payment JSON in the browser; it does not upload data or perform reconciliation.

- `index.html` — the documentation page.
- `preflight.html` and `assets/payment-preflight.mjs` — local JSON preflight and its UI.
- `assets/` — brand-styled CSS and images.
- `.nojekyll` — tells GitHub Pages to serve files as-is.

Run the preflight tests with `node --test tests/*.test.mjs` (Node 22+).

Source of truth for the content is the repositories
[`ledger-parity-core`](https://github.com/LedgerParity/ledger-parity-core),
[`ledger-parity-connectors`](https://github.com/LedgerParity/ledger-parity-connectors),
Expand All @@ -17,4 +20,4 @@ Keep claims and status rows in sync with their `DECISIONS.md`, `ROADMAP.md`,
`PROJECT_HANDOFF.md`, and `docs/*`.

MIT licensed. Brand usage and honesty rules from the organization's
`GOVERNANCE.md` remain applicable.
`GOVERNANCE.md` remain applicable.
118 changes: 118 additions & 0 deletions assets/payment-preflight.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
const MAX_STROOPS = 9223372036854775807n;
const AMOUNT_PATTERN = /^-?[0-9]+(?:\.[0-9]+)?$/;
const RFC3339_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
const REQUIRED = ["id", "network", "operation_type", "sender", "recipient", "amount", "asset", "asset_type", "status"];
const ALLOWED = new Set([
...REQUIRED,
"source_app", "operation_id", "reference_id", "business_reference", "asset_issuer", "asset_contract",
"timestamp", "settlement_start", "settlement_end", "metadata",
]);

function isNonEmptyString(value) {
return typeof value === "string" && value.length > 0;
}

function parseRFC3339(value) {
if (typeof value !== "string") return null;
const match = RFC3339_PATTERN.exec(value);
if (!match) return null;
const datePart = value.slice(0, 19);
const [year, month, day, hour, minute, second] = datePart.split(/[-T:]/).map(Number);
const normalized = new Date(0);
normalized.setUTCFullYear(year, month - 1, day);
normalized.setUTCHours(hour, minute, second, 0);
if (normalized.getUTCFullYear() !== year || normalized.getUTCMonth() + 1 !== month || normalized.getUTCDate() !== day || hour > 23 || minute > 59 || second > 59) return null;
const zone = value.endsWith("Z") ? "Z" : value.slice(-6);
if (zone !== "Z" && (Number(zone.slice(1, 3)) > 23 || Number(zone.slice(4, 6)) > 59)) return null;
const time = Date.parse(value);
return Number.isFinite(time) ? time : null;
}

function validateAmount(value) {
if (typeof value !== "string" || !AMOUNT_PATTERN.test(value)) {
return "amount must be a decimal string";
}
const [whole, fraction = ""] = value.split(".");
if (fraction.length > 7) return "amount exceeds 7 decimal places";
if (value.startsWith("-")) return "amount must be positive and fit the signed int64 stroop range";
const stroops = BigInt(whole) * 10000000n + BigInt((fraction + "0000000").slice(0, 7));
if (stroops <= 0n || stroops > MAX_STROOPS) {
return "amount must be positive and fit the signed int64 stroop range";
}
return null;
}

function validateAsset(record) {
const { asset_type: type, asset: code, asset_issuer: issuer = "", asset_contract: contract = "" } = record;
if (typeof issuer !== "string" || typeof contract !== "string") return "asset issuer and contract must be strings";
if (contract !== "") return "contract tokens are unsupported";
if (type === "native" && code === "XLM" && issuer === "") return null;
if (type === "credit_alphanum4" && typeof code === "string" && /^[a-zA-Z0-9]{1,4}$/.test(code) && issuer !== "") return null;
if (type === "credit_alphanum12" && typeof code === "string" && /^[a-zA-Z0-9]{5,12}$/.test(code) && issuer !== "") return null;
return "asset identity is incomplete or unsupported";
}

function validateRecord(record, index) {
const errors = [];
if (!record || typeof record !== "object" || Array.isArray(record)) {
return { index, errors: ["record must be a JSON object"] };
}
for (const key of Object.keys(record)) {
if (!ALLOWED.has(key)) errors.push(`unknown field: ${key}`);
}
for (const key of REQUIRED) {
if (!isNonEmptyString(record[key])) errors.push(`${key} must be a non-empty string`);
}
if (record.operation_type !== "payment") errors.push("only ordinary classic payment records are supported");

const amountError = validateAmount(record.amount);
if (amountError) errors.push(amountError);
const assetError = validateAsset(record);
if (assetError) errors.push(assetError);

for (const key of ["timestamp", "settlement_start", "settlement_end"]) {
if (record[key] !== undefined && parseRFC3339(record[key]) === null) errors.push(`${key} must be an RFC3339 timestamp`);
}
const hasTimestamp = record.timestamp !== undefined;
const hasStart = record.settlement_start !== undefined;
const hasEnd = record.settlement_end !== undefined;
if (hasTimestamp === (hasStart || hasEnd)) {
errors.push("provide exactly one timestamp or a complete settlement_start/settlement_end interval");
}
if (hasStart !== hasEnd) errors.push("settlement_start and settlement_end must be supplied together");
if (hasStart && hasEnd) {
const start = parseRFC3339(record.settlement_start);
const end = parseRFC3339(record.settlement_end);
if (start !== null && end !== null && end < start) errors.push("settlement_end must not be before settlement_start");
}
for (const key of ["source_app", "operation_id", "reference_id", "business_reference", "asset_issuer", "asset_contract"]) {
if (record[key] !== undefined && typeof record[key] !== "string") errors.push(`${key} must be a string`);
}
if (record.metadata !== undefined && (!record.metadata || typeof record.metadata !== "object" || Array.isArray(record.metadata) || Object.values(record.metadata).some((value) => typeof value !== "string"))) {
errors.push("metadata must be an object");
}
return { index, errors };
}

export function checkPaymentJSON(input) {
if (typeof input !== "string" || new TextEncoder().encode(input).byteLength > 256 * 1024) {
return { error: "Input exceeds the 256 KiB browser limit.", records: [] };
}
let records;
try {
records = JSON.parse(input);
} catch {
return { error: "Input is not valid JSON.", records: [] };
}
if (!Array.isArray(records)) return { error: "Expected one JSON array of payment records.", records: [] };
return { error: null, records: records.map(validateRecord) };
}

export const examplePaymentJSON = JSON.stringify([
{
id: "invoice-1001", source_app: "example-store", network: "Test SDF Network ; September 2015",
operation_type: "payment", sender: "GEXAMPLE-SENDER", recipient: "GEXAMPLE-RECIPIENT",
amount: "12.3400000", asset: "XLM", asset_type: "native", timestamp: "2026-10-01T12:00:00Z", status: "completed",
business_reference: "order-1001",
},
], null, 2);
39 changes: 39 additions & 0 deletions assets/preflight-ui.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { checkPaymentJSON, examplePaymentJSON } from "./payment-preflight.mjs";

const input = document.querySelector("#payment-json");
const summary = document.querySelector("#result-summary");
const results = document.querySelector("#record-results");

function showResult() {
results.replaceChildren();
const checked = checkPaymentJSON(input.value);
if (checked.error) {
summary.textContent = checked.error;
summary.className = "result-error";
return;
}
const invalid = checked.records.filter(({ errors }) => errors.length > 0).length;
summary.textContent = checked.records.length === 0
? "Valid empty JSON array. The CLI treats this as an empty export."
: `${checked.records.length - invalid} of ${checked.records.length} record${checked.records.length === 1 ? "" : "s"} passed the browser preflight${invalid ? `; ${invalid} need${invalid === 1 ? "s" : ""} attention` : ""}.`;
summary.className = invalid ? "result-error" : "result-success";
for (const { index, errors } of checked.records) {
const item = document.createElement("li");
item.className = errors.length ? "record-invalid" : "record-valid";
item.textContent = errors.length ? `Record ${index + 1}: ${errors.join("; ")}` : `Record ${index + 1}: format checks passed`;
results.append(item);
}
}

document.querySelector("#check-json").addEventListener("click", showResult);
document.querySelector("#load-sample").addEventListener("click", () => {
input.value = examplePaymentJSON;
showResult();
});
document.querySelector("#clear-json").addEventListener("click", () => {
input.value = "";
results.replaceChildren();
summary.textContent = "Paste an export and choose “Check JSON.”";
summary.className = "";
input.focus();
});
14 changes: 14 additions & 0 deletions assets/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,20 @@ td { color: var(--slate); }
td b { color: var(--text); }
footer { border-top: 1px solid var(--line); padding: 32px 24px; text-align: center; color: var(--slate); font-size: var(--type-small); }
footer a { color: var(--slate); }
.preflight-hero { padding-top: 56px; }
.preflight-hero h1 { font-size: var(--type-section); margin: 0 0 16px; }
.preflight-hero .lead { font-size: var(--type-subhead); color: var(--slate); max-width: 72ch; }
.field-label { display: block; font-weight: 600; margin: 24px 0 8px; }
textarea { display: block; width: 100%; min-height: 320px; padding: 16px; resize: vertical; color: var(--text); background: var(--code-bg); border: 1px solid var(--slate); border-radius: 12px; font: 0.9rem/1.5 "Cascadia Code", Consolas, monospace; }
textarea:focus-visible, button:focus-visible { outline: 2px solid var(--teal); outline-offset: 3px; }
.preflight-actions { justify-content: flex-start; }
button.button { cursor: pointer; font: inherit; }
.result-success { color: var(--teal); font-weight: 700; }
.result-error { color: #ffb4ab; font-weight: 700; }
.record-results { padding-left: 28px; }
.record-results li { padding: 10px 12px; margin: 8px 0; border-radius: 8px; overflow-wrap: anywhere; }
.record-valid { color: var(--teal); background: rgba(63,224,196,.08); }
.record-invalid { color: #ffb4ab; background: rgba(255,180,171,.08); }
/* Reading, interaction, and responsive accessibility. */
body { overflow-wrap: anywhere; }
section, main { scroll-margin-top: 128px; }
Expand Down
3 changes: 2 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<nav aria-label="Main navigation">
<a href="#overview">Overview</a>
<a href="#quickstart">Quick start</a>
<a href="preflight.html">Local preflight</a>
<a href="#evidence">Evidence</a>
<a href="https://github.com/LedgerParity">GitHub ↗</a>
</nav>
Expand Down Expand Up @@ -298,4 +299,4 @@ <h2>Governance and community</h2>
</footer>

</body>
</html>
</html>
64 changes: 64 additions & 0 deletions preflight.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Local payment JSON preflight — LedgerParity</title>
<meta name="description" content="Check a LedgerParity payment JSON export in your browser before using the read-only CLI.">
<link rel="icon" type="image/png" href="assets/favicon-32.png">
<link rel="stylesheet" href="assets/style.css">
<script type="module" src="assets/preflight-ui.mjs"></script>
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header class="topbar">
<div class="topbar-inner">
<a href="index.html"><img src="assets/lockup.png" alt="LedgerParity home"></a>
<nav aria-label="Main navigation">
<a href="index.html#overview">Overview</a>
<a href="index.html#quickstart">Quick start</a>
<a href="preflight.html" aria-current="page">Local preflight</a>
<a href="https://github.com/LedgerParity">GitHub <span aria-hidden="true">↗</span></a>
</nav>
</div>
</header>
<main id="main" tabindex="-1">
<section class="preflight-hero" aria-labelledby="title">
<p class="eyebrow">BROWSER-ONLY FORMAT CHECK</p>
<h1 id="title">Check a payment export.</h1>
<p class="lead">Catch common JSON shape, identity, amount, and time errors before you run the LedgerParity CLI.</p>
<div class="note">
<p><b>Your data stays in this page.</b> The checker has no upload or network code. Do not paste real customer data into shared or recorded browser sessions. This preflight is an early format check; the CLI remains authoritative.</p>
</div>
</section>
<section aria-labelledby="input-title">
<h2 id="input-title">Payment JSON</h2>
<p>Paste one JSON array of canonical payment records. Amounts must be decimal strings; use <code>"[]"</code> for an empty export.</p>
<label class="field-label" for="payment-json">JSON export</label>
<textarea id="payment-json" spellcheck="false" autocomplete="off" autocapitalize="off" aria-describedby="input-help" placeholder='[{"id":"payment-1", ...}]'></textarea>
<p id="input-help" class="small">The input is limited to 256 KiB in this browser tool. Nothing is sent to a server.</p>
<div class="hero-actions preflight-actions">
<button class="button primary" id="check-json" type="button">Check JSON</button>
<button class="button secondary" id="load-sample" type="button">Load example</button>
<button class="button secondary" id="clear-json" type="button">Clear</button>
</div>
</section>
<section aria-labelledby="result-title">
<h2 id="result-title">Preflight result</h2>
<p id="result-summary" role="status" aria-live="polite">Paste an export and choose “Check JSON.”</p>
<ol id="record-results" class="record-results"></ol>
</section>
<section aria-labelledby="limits-title">
<h2 id="limits-title">What this check covers</h2>
<ul>
<li>Required payment fields, supported classic payment type, and known field names.</li>
<li>Positive decimal-string amounts with at most seven fractional digits and the signed int64 stroop limit.</li>
<li>Native XLM or complete classic credit-asset identity; contract tokens are rejected.</li>
<li>One valid timestamp or one ordered settlement interval.</li>
</ul>
<p>It does not fetch Stellar data, test whether a payment settled, compare records, establish export completeness, or produce evidence bundles. Continue with the <a href="index.html#quickstart">CLI quick start</a> for validation and reconciliation.</p>
</section>
</main>
<footer><p>LedgerParity · <a href="index.html">Documentation home</a> · <a href="https://github.com/LedgerParity">GitHub</a></p></footer>
</body>
</html>
43 changes: 43 additions & 0 deletions tests/payment-preflight.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import test from "node:test";
import assert from "node:assert/strict";
import { checkPaymentJSON, examplePaymentJSON } from "../assets/payment-preflight.mjs";

const valid = JSON.parse(examplePaymentJSON)[0];
const check = (record) => checkPaymentJSON(JSON.stringify([record])).records[0].errors;

test("accepts the documented synthetic native XLM record", () => {
assert.deepEqual(checkPaymentJSON(examplePaymentJSON).records[0].errors, []);
});

test("accepts an explicit closed settlement interval", () => {
const record = { ...valid, timestamp: undefined, settlement_start: "2026-10-01T12:00:00Z", settlement_end: "2026-10-01T13:00:00Z" };
delete record.timestamp;
assert.deepEqual(check(record), []);
});

test("rejects amount precision and signed-int64 overflow", () => {
assert.ok(check({ ...valid, amount: "1.00000001" }).some((error) => error.includes("7 decimal places")));
assert.ok(check({ ...valid, amount: "922337203685.4775808" }).some((error) => error.includes("int64")));
assert.ok(check({ ...valid, amount: "-0.1" }).some((error) => error.includes("positive")));
});

test("rejects incomplete credit asset identity and token contracts", () => {
assert.ok(check({ ...valid, asset: "USD", asset_type: "credit_alphanum4" }).some((error) => error.includes("asset identity")));
assert.ok(check({ ...valid, asset_contract: "C123" }).some((error) => error.includes("contract tokens")));
});

test("rejects unknown fields and ambiguous time representations", () => {
assert.ok(check({ ...valid, memo: "not a reference" }).some((error) => error.includes("unknown field: memo")));
assert.ok(check({ ...valid, settlement_start: "2026-10-01T12:00:00Z", settlement_end: "2026-10-01T13:00:00Z" }).some((error) => error.includes("exactly one")));
assert.ok(check({ ...valid, timestamp: "2026-02-30T12:00:00Z" }).some((error) => error.includes("RFC3339")));
});

test("requires an array and reports malformed JSON safely", () => {
assert.match(checkPaymentJSON("{").error, /not valid JSON/);
assert.match(checkPaymentJSON(JSON.stringify(valid)).error, /JSON array/);
assert.match(checkPaymentJSON("x".repeat(256 * 1024 + 1)).error, /256 KiB/);
});

test("accepts an empty array without calling it reconciliation success", () => {
assert.deepEqual(checkPaymentJSON("[]"), { error: null, records: [] });
});
Loading