From 0c97bd30c242d535389e45c02bbf3078ff722af7 Mon Sep 17 00:00:00 2001 From: OTimileyin <279148355+OTimileyin@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:19:10 +0100 Subject: [PATCH] feat(site): add local payment JSON preflight --- .github/workflows/test.yml | 20 ++++++ README.md | 7 +- assets/payment-preflight.mjs | 118 +++++++++++++++++++++++++++++++ assets/preflight-ui.mjs | 39 ++++++++++ assets/style.css | 14 ++++ index.html | 3 +- preflight.html | 64 +++++++++++++++++ tests/payment-preflight.test.mjs | 43 +++++++++++ 8 files changed, 305 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 assets/payment-preflight.mjs create mode 100644 assets/preflight-ui.mjs create mode 100644 preflight.html create mode 100644 tests/payment-preflight.test.mjs 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

- \ No newline at end of file + diff --git a/preflight.html b/preflight.html new file mode 100644 index 0000000..1f9d93f --- /dev/null +++ b/preflight.html @@ -0,0 +1,64 @@ + + + + + + Local payment JSON preflight — LedgerParity + + + + + + + +
+ +
+
+
+

BROWSER-ONLY FORMAT CHECK

+

Check a payment export.

+

Catch common JSON shape, identity, amount, and time errors before you run the LedgerParity CLI.

+
+

Your data stays in this page. 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.

+
+
+
+

Payment JSON

+

Paste one JSON array of canonical payment records. Amounts must be decimal strings; use "[]" for an empty export.

+ + +

The input is limited to 256 KiB in this browser tool. Nothing is sent to a server.

+
+ + + +
+
+
+

Preflight result

+

Paste an export and choose “Check JSON.”

+
    +
    +
    +

    What this check covers

    +
      +
    • Required payment fields, supported classic payment type, and known field names.
    • +
    • Positive decimal-string amounts with at most seven fractional digits and the signed int64 stroop limit.
    • +
    • Native XLM or complete classic credit-asset identity; contract tokens are rejected.
    • +
    • One valid timestamp or one ordered settlement interval.
    • +
    +

    It does not fetch Stellar data, test whether a payment settled, compare records, establish export completeness, or produce evidence bundles. Continue with the CLI quick start for validation and reconciliation.

    +
    +
    + + + diff --git a/tests/payment-preflight.test.mjs b/tests/payment-preflight.test.mjs new file mode 100644 index 0000000..96dfd6f --- /dev/null +++ b/tests/payment-preflight.test.mjs @@ -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: [] }); +});