@vtex/payment-templates-validator
v0.1.0
Published
Shared validation library for Payment Templates
Maintainers
Readme
payment-templates-validator
The shared validation library of the Dynamic Payment Templates project. It exposes exactly one function, which decides whether a submitted template bundle is safe and well-formed:
import { validate } from '@vtex/payment-templates-validator';
const result = await validate({ template, icon, displayName });
// { ok: false, errors: [{ rule: 'htmlSafety', severity: 'error', message: '…', ref: { file: 'index.html', line: 2 } }] }ok is true iff no finding has severity: 'error'. Validation failures are
always reported through errors[] — validate() only throws for programmer
errors, such as an input carrying none of template / icon / displayName.
Local runs are feedback only
This library is Layer 1 of a defense-in-depth model and it protects only the legitimate write path — a malicious template submitted through the pipeline. It does not protect against direct S3 tampering (that is Layers 2–4: handler wrap, host confinement, IAM/auditing).
The authoritative execution is the server-side one inside
payment-templates-handler, which runsvalidate()before writing a single byte to S3. Running the validator locally frompayment-mocker, or in the browser from the Admin UI, certifies nothing: it emits no trust flag, signature or attestation, and the handler always recomputes. A passing local run is a fast feedback loop, not a guarantee of publication.
Install
yarn add @vtex/payment-templates-validatorRequires Node >= 18. The same build runs in evergreen browsers: bytes are typed
as Uint8Array (never Buffer), text is decoded with TextDecoder, and the
package imports no Node built-in.
Input
Every file is a FileEntry { name, size, buffer: Uint8Array }, where size must
equal buffer.byteLength. A ValidationInput carries any combination of the
three parts, and at least one must be present:
interface ValidationInput {
template?: {
html: FileEntry; // index.html
css: FileEntry; // style.css
i18n: Record<string, FileEntry>; // at least one locale
assets?: FileEntry[]; // raster assets referenced by html/css
defaultLocale: string;
};
icon?: FileEntry;
displayName?: Record<string, string>; // per-locale plain text
}The size / buffer.byteLength equality is the caller's responsibility: size
is what maxFileSize caps, so an understated one would slip past the caps. A
mismatch is therefore a programmer error and validate() throws a TypeError
for it instead of returning a finding. A consumer that assembles FileEntry
from an upload — multipart parts, say — must establish the invariant before
calling, by deriving size from buffer.byteLength; otherwise a malformed
upload surfaces as a server error rather than as a rejected request.
Which rules run depends on what the input carries — a full publish submits all three, while a metadata edit submits an icon and/or display name alongside the currently stored template:
| Input carries | Rules invoked |
|---|---|
| template | htmlSafety, noExternalRefs, i18nKeyConsistency, cssClassUsage, assetUsage, localeFormat, imageSafety (assets), and maxFileSize over every submitted file (the icon included) |
| icon | imageSafety (icon bytes); maxFileSize when the icon is the whole submission |
| displayName | localeFormat (keys), displayNameSafety (values), and displayNameConsistency when a template is also present |
maxFileSize is a whole-submission rule and is evaluated exactly once per
call, so its findings never appear twice.
The ten rules
The catalog is frozen: adding, removing or renaming a rule — or changing any decision boundary, including the three lists — is a breaking change and a MAJOR bump.
| Rule | Decides |
|---|---|
| htmlSafety | Only the 39 allowListed tags may appear — no interactive form control (label, select, option, input, button) is among them; a blockList additionally rejects <script>/<iframe>/<object>/<embed>, every on* attribute and every javascript: URI. <html>/<head>/<body>/<frameset> are rejected too, attributes included — HTML fragment parsing discards them, so they are recovered from a document-mode parse rather than passing unseen |
| noExternalRefs | Every URL resolves inside the bundle; fragment-only refs pass, any scheme (data: included) does not. Covers URLs written as CSS strings (@import "…", image-set("…" 1x)) as well as url() |
| maxFileSize | Bundle ≤ 1 MB; HTML/CSS ≤ 128 KB, asset ≤ 256 KB, i18n ≤ 64 KB, icon ≤ 50 KB; file-type allowlist |
| i18nKeyConsistency | Every data-i18n key exists in every locale, and all locales share one key set |
| cssClassUsage | Every class selector in style.css is used by index.html (error); a class used by index.html that no selector defines is a warning, which never blocks publication |
| assetUsage | Every asset is referenced by the HTML or CSS; the icon is exempt |
| localeFormat | Every locale tag matches ^[a-z]{2}-[A-Z]{2}$ |
| imageSafety | Icon and assets are PNG/JPEG/WebP by magic bytes; SVG files are never accepted; the icon fits a 160×160 box with its smallest side ≥ 60 px. Image weight is maxFileSize's cap, not this rule's |
| displayNameSafety | Plain text, no control characters or bidi controls (U+202A–U+202E and U+2066–U+2069), ≤ 90 characters |
| displayNameConsistency | Display-name locales are shipped by the template, and defaultLocale is among both |
Findings come back in a stable order for the same input, and validate() never
mutates the input or its buffers.
Contributing and the release process: see CONTRIBUTING.md.
