@vtex/payment-templates-core
v1.0.0
Published
Shared validation and wrap-runtime library for Payment Templates
Maintainers
Readme
payment-templates-core
The shared validation and wrap-runtime library of the Dynamic Payment
Templates project. It exposes validate(), which decides whether a submitted
template bundle is safe and well-formed, and wrapTemplate(), which turns that
bundle into the HTML document the sandboxed iframe loads:
import { validate } from '@vtex/payment-templates-core';
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.
A local run is advisory: it emits no trust flag, signature, or attestation, and the authoritative validation runs server-side.
Install
yarn add @vtex/payment-templates-coreRequires 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 is a MAJOR bump,
because the catalog is the RuleName enum and that enum is a frozen contract
(constitution Principle IV). Changing any decision boundary — including the
rules' allowLists and blockLists — is a MINOR bump at any version, because what moves is one rule's
threshold and not the contract. Both need an RFC amendment first; a change to a
finding's message is a PATCH, as is dropping a finding that can only accompany
another error and cannot change whether a bundle passes.
| 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>/<frame> are rejected too, attributes included — HTML fragment parsing discards them, so they are recovered from a document-mode parse rather than passing unseen. Tags must also balance: every non-void element needs its own explicit end tag, and a stray end tag, an element left open at end of input (including mid-file, the moment a breakout tag exits an <svg> an authored </svg> never got to — specs/003-html-tag-balance/spec.md, rule 3), an end tag that closes across an open element, or a run of the file a parse5 recovery swallowed rather than tokenized (seventeen codes, eleven partner-facing messages — an unterminated comment among them) is an error — one finding per offending token or occurrence, balance findings in source order among themselves, with every swallowed-markup finding reported after all of them regardless of its own position in the file. /> closes an element only in foreign content, so <svg><path/></svg> passes while <div/> leaves the div open. An end tag HTML makes optional (</p>, </li>, …) is required here, and the finding says so |
| 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. Locale files are nested JSON objects: an empty or dotted key name is an error, as is a value that is not nested content or text. A data-i18n reference with an empty segment, or one that is a segment-wise ancestor of another, is an error |
| 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.
Wrap runtime
wrapTemplate is exported from the package root next to validate(). It is
not a second validator: it does not decide whether a bundle is safe to publish.
resolveLocale stays on @vtex/payment-templates-core/wrap because it is a
require() of the verbatim CJS file. Full argument:
docs/rfcs/rfc-wrap-runtime.md.
import { wrapTemplate, DIAGNOSTIC_CODES, buildThemeTokenStyle } from '@vtex/payment-templates-core';
import { resolveLocale } from '@vtex/payment-templates-core/wrap';
const html = wrapTemplate(bundle, 'en-US', 'http://localhost:8080', themeTokens);The two iframe scripts are published verbatim (no transpile). Resolve them
with require.resolve and serve the bytes as-is:
require.resolve('@vtex/payment-templates-core/wrap/template-runtime.js')
require.resolve('@vtex/payment-templates-core/wrap/resolve-locale.js')wrapTemplate emits <script src="/lib/resolve-locale.js"> then
<script src="/lib/template-runtime.js">. A consumer that uses those URLs
(payment-mocker) must serve the packaged files at /lib/. A consumer that
assembles the document itself (checkout-ui) keeps its assembler and copies the
same two files from this package into /lib/payment-template/ at build time.
Pin the dependency without a caret, matching payment-mocker today:
"@vtex/payment-templates-core": "1.0.0"Follow-up: payment-mocker
Separate PR, after this package is published:
- Depend on the released version (no
^). - Replace
lib/wrap-template.js,lib/theme-tokens.js,lib/locale-tag.js,lib/origin-pattern.jswith imports from@vtex/payment-templates-core(wrapTemplatefrom the root; helpers may come from the root or./wrap). - Point
lib/preview-middleware.jsroutes that currentlystreamFile(path.join(__dirname, 'resolve-locale.js'))(and the runtime equivalent) atrequire.resolve('@vtex/payment-templates-core/wrap/…'). - Update
invalidateWrapCache()so it no longer deletes those local modules. - Retarget the byte-for-byte assertions in
test/preview-middleware.test.js(~lines 704 and 728) at the packaged files. - Import
DIAGNOSTIC_CODESinsrc/assets/libs/template-host.jsinstead of keeping a private array. The host file itself stays in the mocker. - Run the mocker suite and a manual
gruntpreview smoke test.
Do not change wrap behavior in that PR.
Follow-up: vcs.checkout-ui
Separate PR, after payment-mocker, and not in the same change as this
extraction. Checkout-ui can consume this package at build time: Grunt (or
the equivalent copy task) takes the two verbatim files from
node_modules/@vtex/payment-templates-core/ and writes them to
/lib/payment-template/. The iframe cannot import from npm at runtime —
CSP default-src 'none' — so "consume the lib" means that copy, not a
runtime import.
Checkout-ui currently vendors:
| Packaged file | Vendored copy today |
| --- | --- |
| wrap/template-runtime.js | src/lib/payment-template/template-runtime.js |
| wrap/resolve-locale.js | src/lib/payment-template/resolve-locale.js |
| buildThemeTokenStyle / token patterns | ES5 twin inside src/script/payment/payment-template-document.js |
| DIAGNOSTIC_CODES | hardcoded list in src/script/payment/dynamic-payment-templates.js |
The vendored JS files carry a checkout-ui preamble (@vtex-vendored:begin …
@vtex-vendored:end) and a byte-for-byte CI comparator that strips it and
diffs the rest against vtex/payment-mocker — today's upstream, reachable
only by hand-copying.
That whole apparatus (preamble, comparator, manual re-sync PRs) exists only
because there is no single, installable source of truth yet: payment-mocker
and checkout-ui each hold their own copy, and something has to catch the two
drifting apart. Once checkout-ui depends on @vtex/payment-templates-core
and its build copies the two scripts straight from
node_modules/@vtex/payment-templates-core/dist/wrap/runtime/… at build
time, that stops being true: there is exactly one committed copy left (this
package's own src/wrap/runtime/*.js), and checkout-ui's package.json
version pin is what determines which bytes it gets — the same trust every
other npm dependency already carries, not something a second, repo-specific
comparator needs to re-verify. This package's own build already guarantees
dist/ matches a reviewed src/ (yarn check:runtime-bytes, backed by the
committed checksums in scripts/runtime-checksums.json); that is the one
byte-for-byte guarantee that still needs to exist, and it already does.
- Depend on
@vtex/payment-templates-coreand switch the build's copy step to read fromnode_modules/@vtex/payment-templates-core/dist/wrap/runtime/…instead of the committedsrc/lib/payment-template/*.js. - Delete the vendored files, the
@vtex-vendoredpreamble, and the byte-for-byte comparator together — there is no second copy left for any of them to protect.
payment-template-document.js is not wrapTemplate. This package's
wrapTemplate emits /lib/ script URLs; checkout-ui builds script URLs from
its own CDN / /lib/payment-template/ layout. Host code can import
DIAGNOSTIC_CODES and the theme-token helpers from the package through the
build pipeline. Do not call wrapTemplate from checkout-ui until a later RFC
adds a script-directory argument with the current /lib/ default preserved.
Contributing and the release process: see CONTRIBUTING.md.
