@pmx-trust/sdk
v1.0.1
Published
PMX TRUST SDK — universal (Node + browser) file proof submission, keccak-256 hashing, canonicalization, and evidence bundle handling
Readme
@pmx-trust/sdk
Universal TypeScript SDK for the PMX Trust platform — runs in Node.js and the browser from a single import.
Submit files for tamper-proof, blockchain-anchored proofs. The SDK hashes files locally with keccak-256, applies format-specific canonicalization (PDF metadata stripping, exc-c14n for XML, byte-level for images), and submits only the hash + metadata to the PMX API. Original files never leave your infrastructure.
Highlights
- Universal — same
PMXTrustClientworks in Node, browsers, Next.js, edge runtimes - Zero native dependencies — pure JavaScript, no
sharp, no native bindings - Format canonicalization — PDF, DOCX, XLSX, PPTX, XML, JPG, PNG produce byte-identical hashes regardless of metadata variation
- Type-safe — full TypeScript types for every request/response
- Tree-shakable — Node-only helpers (S3, file paths) split into a
/nodesubpath - Small — ~40 KB ESM, ~22 KB gzipped
Install
npm install @pmx-trust/sdk
# or
pnpm add @pmx-trust/sdk
# or
yarn add @pmx-trust/sdkRequires Node.js >=20 for server-side use. Browser builds work in any modern browser (uses fetch + Web Crypto, both standard since 2021).
Quick start
In a React app (browser)
import { PMXTrustClient } from "@pmx-trust/sdk";
const client = new PMXTrustClient({
apiKey: import.meta.env.VITE_PMX_API_KEY,
});
async function onUpload(file: File) {
// `submit` reads file.name, file.type, file.size automatically
const proof = await client.submit(file);
console.log("Proof submitted:", proof.id);
// Poll for confirmation
const confirmed = await client.waitForConfirmation(proof.id);
console.log("Status:", confirmed.status);
}In a Node.js script
import { PMXTrustClient } from "@pmx-trust/sdk";
const client = new PMXTrustClient({
apiKey: process.env.PMX_API_KEY!,
});
// Path-based — Node only
const proof = await client.submitFile("./contract.pdf");
const confirmed = await client.waitForConfirmation(proof.id);
console.log("Confirmed at:", confirmed.confirmedAt);The same import works in both. Path-based methods (submitFile, etc.) throw BrowserUnsupportedError if invoked from the browser; the universal submit(blob | uint8array | file) works everywhere.
Constructor options
new PMXTrustClient({ apiKey, timeout, s3Config });| Option | Type | Default | Notes |
| ---------- | ---------------- | -------- | ------------------------------------------------------- |
| apiKey | string | — | Required. Format: pmx_<64-char-hex> |
| timeout | number (ms) | 30_000 | Per-request timeout |
| s3Config | S3ClientConfig | — | Defaults for S3 helpers (region, endpoint, credentials) |
Authentication is via the x-api-key header. The SDK sends it on every request; you don't manage headers yourself.
Internal options (
environment,baseUrl,sdkVersion) exist for local dev / QA testing and are not part of the public API surface — see SDK source if you need them.
Methods
Universal — work in Node and browser
| Method | Signature | Returns |
| --------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------- |
| submit | (input: File \| Blob \| Uint8Array, options?: SubmitOptions) => Promise<SubmitProofResponse> | The submitted proof |
| hash | (input: File \| Blob \| Uint8Array, options?: SubmitOptions) => Promise<HashResult> | Local hash, no API call |
| verify | (input: File \| Blob \| Uint8Array, options?: SubmitOptions) => Promise<VerificationResult> | Verification result |
| getProof | (proofId: string) => Promise<ProofDetail> | Current proof state |
| verifyHash | (keccak256Hash: string) => Promise<VerificationResult> | Verify by hash only |
| waitForConfirmation | (proofId: string, options?: WaitOptions) => Promise<ProofDetail> | Polls until CONFIRMED or FAILED |
SubmitOptions lets you override file metadata: { fileName?: string; mimeType?: string }. When you pass a File, the SDK auto-extracts name and type. For raw Blob or Uint8Array, pass fileName so the SDK can detect the format and apply canonicalization.
Node-only — throw BrowserUnsupportedError in the browser
| Method | Signature |
| ---------------- | ----------------------------------------------------------------------------------------- |
| submitFile | (filePath: string) => Promise<SubmitProofResponse> |
| hashFile | (filePath: string) => Promise<HashResult> |
| verifyFile | (filePath: string) => Promise<VerificationResult> |
| submitStream | (stream: Readable, mimeType?: string) => Promise<SubmitProofResponse> |
| submitBatch | (filePaths: string[], options?: { concurrency?: number }) => Promise<BatchSubmitResult> |
| submitS3Object | (ref: S3ObjectRef) => Promise<SubmitProofResponse> |
| hashS3Object | (ref: S3ObjectRef) => Promise<HashResult> |
| verifyS3Object | (ref: S3ObjectRef) => Promise<VerificationResult> |
These methods exist on the same PMXTrustClient class but require Node.js. Calling them from a browser throws BrowserUnsupportedError with a message pointing at the universal alternative.
S3 helpers depend on @aws-sdk/client-s3, which is declared as an
optionalDependencies entry — npm installs it alongside the SDK by
default, so submitS3Object / hashS3Object / verifyS3Object work
out of the box. If you installed with --no-optional (or your registry
couldn't fetch it), the SDK throws at the first S3 call with a
message pointing at npm install @aws-sdk/client-s3 as the fix.
Listing objects (universal — Node + browser)
listS3Objects is the one S3 method that doesn't need streaming, so
it works from both Node and a browser bundle. The other S3 methods
(submit/hash/verify) stay Node-only.
listS3Objects(options: S3ListOptions): Promise<S3ListResult>Folder navigation is the default — delimiter: '/' returns files
directly under prefix in objects, and sub-folders in
commonPrefixes. Pass delimiter: '' for a recursive flat listing.
Caller drives pagination via continuationToken (one S3 call per
invocation).
Node example — uses the AWS default credential chain
(AWS_* env vars, ~/.aws/credentials, IAM role):
import { PMXTrustClient } from '@pmx-trust/sdk';
const client = new PMXTrustClient({ apiKey: 'pmx_...' });
const result = await client.listS3Objects({
bucket: 'my-bucket',
prefix: 'contracts/',
delimiter: '/',
});
console.log(result.commonPrefixes); // ['contracts/2025/', 'contracts/2026/']
console.log(result.objects); // files directly under contracts/Browser example — credentials MUST come from Cognito / STS / your
backend. Never ship long-lived accessKeyId/secretAccessKey to a
browser bundle:
import { PMXTrustClient } from '@pmx-trust/sdk';
import { fromCognitoIdentityPool } from '@aws-sdk/credential-providers';
const client = new PMXTrustClient({
apiKey: 'pmx_...',
s3Config: {
region: 'us-east-1',
credentials: fromCognitoIdentityPool({
identityPoolId: 'us-east-1:xxx-xxx-xxx',
clientConfig: { region: 'us-east-1' },
}),
},
});
const result = await client.listS3Objects({ bucket: 'my-bucket', delimiter: '/' });The browser bundle includes a hard guard: calling listS3Objects in a
browser environment without s3Config.credentials throws a PMXError
before any AWS-SDK code loads — no network call is even prepared.
Required bucket CORS for browser usage:
[
{
"AllowedOrigins": ["https://your-app.example.com"],
"AllowedMethods": ["GET"],
"AllowedHeaders": ["*"],
"ExposeHeaders": []
}
]If the bucket isn't configured for CORS the SDK wraps the resulting
browser TypeError("Failed to fetch") into a friendly S3AccessError
with code NetworkOrCors and a pointer at this snippet.
Pagination loop — listS3Objects returns one page per call.
Walk pages via the returned token:
let token: string | undefined;
do {
const page = await client.listS3Objects({
bucket: 'my-bucket',
prefix: 'archive/',
continuationToken: token,
});
// process page.objects, page.commonPrefixes
token = page.nextContinuationToken ?? undefined;
} while (token);Imports
// Universal entry — everything browser-safe + the typed Node-only methods
import {
PMXTrustClient,
canonicalizeBuffer,
detectFormat,
hashBytes,
blobToBytes,
CANON_VERSION,
SDK_VERSION,
IS_BROWSER,
// Errors
PMXError,
ApiError,
TimeoutError,
FileValidationError,
S3AccessError,
BrowserUnsupportedError,
} from "@pmx-trust/sdk";
// Node-only subpath — standalone helpers that take filesystem paths or AWS refs
import {
hashFilePath,
hashStream,
hashStreamWithMeta,
hashS3Object,
resolveFileName,
canonicalize, // file-path version (uses fs.readFileSync)
} from "@pmx-trust/sdk/node";The /node subpath is for callers who prefer the functional style or need direct access to lower-level helpers. The class methods on PMXTrustClient cover all the same operations.
CLI (pmx)
The package ships a pmx binary so you can submit, verify, and inspect proofs from a shell without writing code. Installing the SDK makes pmx available; npx works without a global install.
# One-off, no install
npx @pmx-trust/sdk submit ./contract.pdf
# Global install
npm install -g @pmx-trust/sdk
pmx --helpFirst-time setup
pmx loginPrompts for your API key and environment (production or qa), then writes them to ~/.pmxrc (mode 0600). After this every command picks the credentials up automatically — no --api-key flag needed.
pmx config get # show active profile (apiKey + AWS secret masked)
pmx config set environment qa
pmx logout # clear stored apiKeyOptional: S3 credentials
If you don't already have AWS CLI configured (~/.aws/credentials) and don't want to juggle AWS_* env vars, store S3 creds in ~/.pmxrc:
pmx login s3
# Prompts for: default bucket (optional), region, access key id,
# secret access key, session token (blank for long-lived IAM keys).
pmx logout s3 # clear themSafety rule: the AWS default credential chain always wins. If AWS_ACCESS_KEY_ID is in your env, or ~/.aws/credentials exists, the CLI uses those and ignores what's in ~/.pmxrc. Stored creds are only consulted as a fallback. For SSO, IAM roles, AssumeRole chains, or anything else that rotates automatically, leave pmx login s3 alone and use the AWS chain.
With a default bucket stored, the --s3 flag accepts shorthand:
pmx submit --s3 contracts/acme.pdf # → s3://<default-bucket>/contracts/acme.pdf
pmx submit --s3 s3://other-bucket/k.pdf # full URI always overridesExamples
# Single file
pmx submit ./contract.pdf
# Batch — default concurrency 5
pmx submit ./a.pdf ./b.pdf ./c.pdf --concurrency 3
# S3 — works out of the box (@aws-sdk/client-s3 ships as an
# optional dependency). Requires AWS credentials resolvable by the
# AWS SDK (env vars, ~/.aws/credentials, instance role, etc.).
pmx submit --s3 s3://my-bucket/contracts/acme.pdf
# Lineage — same id across submissions versions them
pmx submit ./contract-v1.pdf --lineage contract:acme:2026-q2
pmx submit ./contract-v2.pdf --lineage contract:acme:2026-q2
# Verify
pmx verify ./contract.pdf
pmx verify --hash 0x3a7f...
pmx verify --s3 s3://my-bucket/contracts/acme.pdf
# Fetch a proof
pmx proof <proof-id> --jsonOutput modes
| Flag | Behaviour |
| ----------- | ---------------------------------------------------------------------- |
| (default) | Coloured human summary; batch results in a table |
| --json | Single JSON object (or array for batch) to stdout — machine-readable |
| --quiet | Proof ids only, one per line — pipe-friendly (xargs, jq, etc.) |
Logs and progress go to stderr; results go to stdout. pmx submit ./*.pdf --json > results.json works cleanly.
Auth resolution order
For every command, pmx resolves the API key, environment, and base URL in this order — first non-empty value wins:
- CLI flag (
--api-key,--environment,--base-url) - Process env (
PMX_API_KEY,PMX_ENVIRONMENT,PMX_BASE_URL) ./.envin the current directory~/.pmxrc(active profile)
| Env var | Notes |
| ----------------- | ----------------------------------------------------------- |
| PMX_API_KEY | pmx_<64-hex> form preferred; raw 64-char hex is accepted |
| PMX_ENVIRONMENT | production or qa |
| PMX_BASE_URL | Overrides PMX_ENVIRONMENT; use for self-host or local dev |
Exit codes
| Code | Condition |
| ---- | -------------------------------------------------------------------- |
| 0 | Success |
| 1 | Unknown / unhandled error |
| 2 | FileValidationError (unsupported MIME, file too large, etc.) |
| 3 | ApiError (non-2xx response from the backend) |
| 4 | TimeoutError (waitForConfirmation deadline exceeded) |
| 5 | S3AccessError — or @aws-sdk/client-s3 missing on a --s3 call |
| 6 | Missing / invalid auth |
Supported formats
The SDK canonicalizes these formats before hashing — files with identical content produce identical hashes regardless of authoring metadata, timestamps, or compression variation:
| Format | MIME | Canonicalization |
| ---------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| PDF | application/pdf | Strip metadata (Title, Author, Producer, Creator, dates, XMP), re-serialize via pdf-lib |
| DOCX, XLSX, PPTX | application/vnd.openxmlformats-… | Zero out core.xml values, remove app.xml/custom.xml/thumbnails, exc-c14n every XML part, deterministic ZIP |
| XML | application/xml | W3C Exclusive XML Canonicalization (exc-c14n) without comments |
| JPEG | image/jpeg | Pure-JS byte stripping: remove APP1–APP15 (EXIF, XMP, ICC) and COM markers, keep image data |
| PNG | image/png | Pure-JS byte stripping: keep critical chunks (IHDR, PLTE, IDAT, IEND), drop all ancillary chunks |
Other file types (text, archives, video, etc.) are hashed as raw bytes — submission still works; just no canonicalization step.
Errors
All errors extend PMXError. Catch by type to handle distinct failure modes:
import {
PMXError,
ApiError,
TimeoutError,
BrowserUnsupportedError,
} from "@pmx-trust/sdk";
try {
await client.submit(file);
} catch (err) {
if (err instanceof ApiError) {
console.error("Backend rejected:", err.statusCode, err.responseBody);
} else if (err instanceof TimeoutError) {
console.error("Took longer than the configured timeout");
} else if (err instanceof BrowserUnsupportedError) {
console.error(`${err.method}() requires Node.js`);
} else if (err instanceof PMXError) {
console.error("SDK error:", err.message);
}
}| Error | Thrown when |
| ------------------------- | --------------------------------------------------------------------- |
| ApiError | Backend returned a non-2xx response |
| TimeoutError | waitForConfirmation exceeded its deadline |
| FileValidationError | File too large, empty, unreadable, or stream exceeded MAX_FILE_SIZE |
| S3AccessError | S3 fetch failed (NoSuchKey, AccessDenied, etc.) |
| BrowserUnsupportedError | Path / stream / S3 method invoked in a browser |
| PMXError | Base class |
Bundling notes
When bundling for the browser (Vite, webpack 5, esbuild), you may see warnings about node:fs, node:stream, or node:path being externalized. These are harmless. The Node-only code paths are guarded by assertNode() and never execute in the browser — the imports just sit in the bundle as stubs. Modern bundlers handle this correctly.
The browser bundle has zero native dependencies. There is no sharp, no xml-crypto, no mime-db — those have all been removed or vendored in.
TypeScript
All public types are exported from the main entry. Common ones:
import type {
PMXTrustClientOptions,
PMXEnvironment,
SubmitProofResponse,
ProofDetail,
VerificationResult,
WaitOptions,
BatchSubmitResult,
HashResult,
SupportedFormat,
S3ObjectRef,
S3ClientConfig,
SubmitOptions,
} from "@pmx-trust/sdk";HashResult is identical whether returned from a universal hash() or a Node hashFilePath() call.
Local development
Working on the SDK itself or against an unreleased version?
# Inside the SDK repo
pnpm install
pnpm build # tsup — produces dist/index.{js,cjs,d.ts} + dist/node/index.{js,cjs,d.ts}
pnpm test # vitest — runs both Node and jsdom suites
pnpm typecheckTo consume an unreleased build in another app, pack and install:
# In this repo
pnpm pack
# Produces pmx-trust-sdk-<version>.tgz
# In your consumer app
pnpm add /path/to/pmx-trust-sdk-<version>.tgzLicense
Proprietary. See package.json for the canonical license declaration.
The XML canonicalization implementation under src/vendor/c14n/ is derived from xml-crypto (MIT) — see src/vendor/c14n/ATTRIBUTION.md for full attribution.
