npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

untamper-sdk

v2.0.0

Published

Official Node.js SDK for unTamper - Enterprise audit logging platform

Readme

unTamper Node.js SDK

npm version Node.js Version TypeScript

Official Node.js SDK for unTamper — enterprise tamper-proof audit logging with cryptographic hash chaining and ECDSA signatures.

Features

  • Type-Safe — Full TypeScript support with comprehensive type definitions
  • Payload Model — Freeform JSON payload blob; you own the schema
  • Client-Side Verification — Recompute hashes locally; no server trust required
  • Blockchain-Style Chaining — JCS canonicalization + SHA-256 + ECDSA per record
  • Idempotent Ingestion — UUID4 idempotency keys; safe to retry
  • Auto-Retry — Exponential backoff on 5xx and 429 errors
  • Zero Runtime Dependencies — Only canonicalize and node-fetch

Installation

npm install untamper-sdk

Quick Start

import { UnTamperClient } from 'untamper-sdk'

const client = new UnTamperClient({
  projectId: process.env.UNTAMPER_PROJECT_ID!,
  apiKey: process.env.UNTAMPER_API_KEY!,
})

// Ingest an audit record — all event data goes inside `payload`
const response = await client.logs.ingestRecord({
  payload: {
    action: 'user.login',
    actor: {
      id: 'user_123',
      type: 'user',
      display_name: 'Alice Smith',
    },
    result: 'SUCCESS',
    ip: '192.168.1.1',
  },
})

console.log('Queued:', response.data?.ingestId)

Configuration

Required

| Option | Description | |--------|-------------| | projectId | Your project UUID | | apiKey | Your project API key |

Optional

| Option | Default | Description | |--------|---------|-------------| | baseUrl | https://app.untamper.com | Override API base URL | | timeout | 30000 | Request timeout in ms | | retryAttempts | 3 | Retry count on 5xx / 429 | | retryDelay | 1000 | Initial retry delay in ms |

const client = new UnTamperClient({
  projectId: 'your-project-id',
  apiKey: 'your-api-key',
  timeout: 10000,
  retryAttempts: 5,
  retryDelay: 2000,
})

API Reference

Log Ingestion

client.logs.ingestRecord(request)

Ingests a single audit record.

The payload field is a freeform JSON blob. unTamper stores it verbatim and includes the entire blob in the integrity hash. Recognized semantic fields (action, actor, result, target, changes, eventTime) are used for filtering and display; all other fields are preserved as custom data.

const response = await client.logs.ingestRecord({
  payload: {
    action: 'document.update',
    actor: {
      id: 'user_123',
      type: 'user',
      display_name: 'Alice Smith',
    },
    target: {
      id: 'doc_456',
      type: 'document',
      display_name: 'Q4 Report',
    },
    result: 'SUCCESS',
    changes: [
      { path: 'title', old_value: 'Draft', new_value: 'Final' },
      { path: 'status', old_value: 'draft', new_value: 'published' },
    ],
    // Custom fields — stored and hashed exactly as submitted
    request_id: 'req_abc123',
    environment: 'production',
  },
})

Idempotency: Pass an explicit idempotencyKey (UUID4) to make the call safe to retry:

import { randomUUID } from 'crypto'

await client.logs.ingestRecord({
  idempotencyKey: randomUUID(),
  payload: {
    action: 'billing.charge_processed',
    actor: { id: 'payment-service', type: 'service' },
    result: 'SUCCESS',
    amount_cents: 4900,
  },
})

If omitted, the SDK auto-generates a UUID4.

client.logs.ingestRecords(requests)

Ingests multiple records in batch (runs concurrently):

const responses = await client.logs.ingestRecords([
  {
    payload: { action: 'user.login', actor: { id: 'user1', type: 'user' } },
  },
  {
    payload: { action: 'user.logout', actor: { id: 'user2', type: 'user' } },
  },
])

client.logs.checkIngestionStatus(ingestId)

Checks the processing status of a submitted record:

const status = await client.logs.checkIngestionStatus('ingest_123')
console.log('Status:', status.data?.status)
// PENDING | PROCESSING | COMPLETED | FAILED | RETRYING

client.logs.waitForCompletion(ingestId, options?)

Polls until a record is processed (COMPLETED or FAILED):

const status = await client.logs.waitForCompletion('ingest_123', {
  pollInterval: 1000,  // ms between polls
  maxWaitTime: 30000,  // give up after 30s
})

client.logs.queryLogs(options?)

Queries records with optional filters and pagination.

// Fetch recent records
const { logs, pagination } = await client.logs.queryLogs({ limit: 50 })

// Filter by actor + result
const failures = await client.logs.queryLogs({
  actorId: 'user_123',
  result: 'FAILURE',
  limit: 10,
})

// Paginate through all records
let offset = 0
while (true) {
  const { logs, pagination } = await client.logs.queryLogs({ limit: 100, offset })
  // process logs...
  if (!pagination.hasMore) break
  offset += logs.length
}

Filter options:

| Option | Description | |--------|-------------| | action | Filter by action name | | actorId | Filter by actor ID | | actorType | Filter by actor type | | result | Filter by result (SUCCESS, FAILURE, DENIED, ERROR) | | limit | Max records (default: 50) | | offset | Pagination offset (default: 0) |

client.logs.getLog(logId)

Fetches a single record by ID:

const { log } = await client.logs.getLog('clx...')

client.logs.healthCheck()

Health check — no auth required:

const health = await client.logs.healthCheck()
console.log('Healthy:', health.success)

Verification

All verification is client-side — the SDK recomputes hashes locally and verifies ECDSA signatures without trusting the server.

Important: Call client.initialize() before any verification. This fetches the project's ECDSA public key.

client.initialize()

Fetches the ECDSA public key for verification:

await client.initialize()

client.verification.verifyLog(record)

Verifies a single record's hash and signature:

await client.initialize()
const { log } = await client.logs.getLog('clx...')

const result = await client.verification.verifyLog(log)
console.log('Valid:', result.valid)
console.log('Hash valid:', result.hashValid)
console.log('Signature valid:', result.signatureValid)

client.verification.verifyLogs(records)

Verifies a sequence of records with full blockchain-style chain validation:

  • Sorts by sequenceNumber ascending
  • Recomputes each hash using JCS canonicalization (RFC 8785) + SHA-256
  • Verifies each ECDSA signature
  • Checks that previousHash links are intact (genesis record: 64 hex zeros; subsequent: prior record's hash)
  • Checks that sequence numbers are consecutive
await client.initialize()
const { logs } = await client.logs.queryLogs({ limit: 200 })

const result = await client.verification.verifyLogs(logs)
console.log('Chain valid:', result.valid)
console.log('Total:', result.totalLogs)
console.log('Valid:', result.validLogs)
console.log('Invalid:', result.invalidLogs)

if (!result.valid) {
  console.log('Chain broken at sequence:', result.brokenAt)
  for (const err of result.errors) {
    console.log(`  seq ${err.sequenceNumber}: ${err.error}`)
  }
}

Crypto Utilities

Exported for advanced use cases (e.g. independent third-party verification):

import { computeRecordHash, verifyECDSASignature, GENESIS_HASH } from 'untamper-sdk'

computeRecordHash(params)

Computes the SHA-256 hash of a record using JCS canonicalization (RFC 8785):

const hash = computeRecordHash({
  payload: record.payload,
  projectId: record.projectId,
  sequenceNumber: record.sequenceNumber,
  previousHash: record.previousHash,
  timestamp: record.timestamp,  // ISO 8601 string
})

Internally applies JCS({ canonicalizationVersion: 'v2', payload, previousHash, projectId, sequenceNumber, timestamp }) then SHA-256. This is deterministic — the same input always produces the same 64-character hex hash, regardless of JSON key insertion order.

verifyECDSASignature(hash, signature, publicKey)

Verifies an ECDSA signature:

const isValid = verifyECDSASignature(hash, record.signature, publicKey)
  • hash — hex string (output of computeRecordHash)
  • signature — base64-encoded ECDSA signature
  • publicKey — PEM-encoded ECDSA public key

GENESIS_HASH

The canonical previousHash value for the first record in a chain:

GENESIS_HASH === '0'.repeat(64)
// "0000000000000000000000000000000000000000000000000000000000000000"

TypeScript

The SDK is written in TypeScript. All types are exported:

import {
  UnTamperClient,
  RecordIngestionRequest,
  RecordPayload,
  Record,
  QueryLogsResponse,
  ChainVerificationResult,
  VerifyLogResult,
  Actor,
  Target,
  ActionResult,
} from 'untamper-sdk'

const request: RecordIngestionRequest = {
  payload: {
    action: 'user.login',
    actor: { id: 'user_123', type: 'user' },
    result: 'SUCCESS' as ActionResult,
  },
}

const response = await client.logs.ingestRecord(request)
const records: Record[] = (await client.logs.queryLogs()).logs

Error Handling

import {
  UnTamperError,
  AuthenticationError,
  ValidationError,
  NetworkError,
  RateLimitError,
  ServerError,
} from 'untamper-sdk'

try {
  await client.logs.ingestRecord({ payload: { action: 'x', actor: { id: '', type: 'user' } } })
} catch (error) {
  if (error instanceof ValidationError) {
    console.error('Invalid request:', error.message, error.details)
  } else if (error instanceof AuthenticationError) {
    console.error('Auth failed — check your API key')
  } else if (error instanceof RateLimitError) {
    console.error('Rate limited — retry after delay')
  } else if (error instanceof NetworkError) {
    console.error('Network error:', error.message)
  } else if (error instanceof ServerError) {
    console.error('Server error:', error.message)
  }
}

Examples

Complete Workflow: Ingest → Query → Verify

import { UnTamperClient } from 'untamper-sdk'

const client = new UnTamperClient({
  projectId: process.env.UNTAMPER_PROJECT_ID!,
  apiKey: process.env.UNTAMPER_API_KEY!,
})

async function run() {
  // 1. Initialize to fetch public key for verification
  await client.initialize()

  // 2. Ingest a record
  const ingest = await client.logs.ingestRecord({
    payload: {
      action: 'user.login',
      actor: { id: 'user_123', type: 'user', display_name: 'Alice' },
      result: 'SUCCESS',
    },
  })
  console.log('Queued:', ingest.data?.ingestId)

  // 3. Wait for processing
  await new Promise(r => setTimeout(r, 2000))

  // 4. Query and verify the chain
  const { logs } = await client.logs.queryLogs({ limit: 100 })
  const chain = await client.verification.verifyLogs(logs)

  console.log('Chain valid:', chain.valid)
  console.log(`${chain.validLogs}/${chain.totalLogs} records verified`)
  if (!chain.valid) {
    console.log('First break at seq:', chain.brokenAt)
  }
}

run().catch(console.error)

Express.js Middleware

import express from 'express'
import { UnTamperClient } from 'untamper-sdk'

const untamper = new UnTamperClient({
  projectId: process.env.UNTAMPER_PROJECT_ID!,
  apiKey: process.env.UNTAMPER_API_KEY!,
})

function auditMiddleware(req: express.Request, res: express.Response, next: express.NextFunction) {
  const start = Date.now()
  const originalSend = res.send

  res.send = function (body) {
    untamper.logs.ingestRecord({
      payload: {
        action: `${req.method.toLowerCase()}.${req.path.replace(/\//g, '.')}`,
        actor: {
          id: (req as any).user?.id ?? 'anonymous',
          type: (req as any).user ? 'user' : 'anonymous',
        },
        result: res.statusCode < 400 ? 'SUCCESS' : 'FAILURE',
        http_method: req.method,
        http_path: req.path,
        status_code: res.statusCode,
        duration_ms: Date.now() - start,
      },
    }).catch(console.error)

    return originalSend.call(this, body)
  }

  next()
}

const app = express()
app.use(auditMiddleware)

Batch Ingestion

const events = [
  { action: 'user.login',  actor: { id: 'user1', type: 'user' } },
  { action: 'user.logout', actor: { id: 'user2', type: 'user' } },
  { action: 'doc.create',  actor: { id: 'user3', type: 'user' } },
]

const responses = await client.logs.ingestRecords(
  events.map(e => ({ payload: e }))
)
console.log(`Submitted ${responses.length} records`)

Development

Setup

git clone https://github.com/untamper/sdk-node.git
cd sdk-node
npm install

Commands

npm run build       # compile to dist/
npm test            # run all tests
npm run test:coverage
npm run lint
npm run lint:fix
npm run type-check

Requirements

  • Node.js 16+
  • TypeScript 4+ (for TypeScript projects)

License

MIT — see LICENSE

Support