diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..132ca96
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -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
diff --git a/README.md b/README.md
index 1712967..0b4ef8d 100644
--- a/README.md
+++ b/README.md
@@ -3,12 +3,15 @@
This repository hosts the LedgerParity documentation site at
.
-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),
@@ -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.
\ No newline at end of file
+`GOVERNANCE.md` remain applicable.
diff --git a/assets/payment-preflight.mjs b/assets/payment-preflight.mjs
new file mode 100644
index 0000000..5d3183f
--- /dev/null
+++ b/assets/payment-preflight.mjs
@@ -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);
diff --git a/assets/preflight-ui.mjs b/assets/preflight-ui.mjs
new file mode 100644
index 0000000..c4dc315
--- /dev/null
+++ b/assets/preflight-ui.mjs
@@ -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();
+});
diff --git a/assets/style.css b/assets/style.css
index 537940c..2a58133 100644
--- a/assets/style.css
+++ b/assets/style.css
@@ -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; }
diff --git a/index.html b/index.html
index bd576cc..0b1032f 100644
--- a/index.html
+++ b/index.html
@@ -17,6 +17,7 @@
@@ -298,4 +299,4 @@
Governance and community