untamper-sdk
v2.0.0
Published
Official Node.js SDK for unTamper - Enterprise audit logging platform
Maintainers
Readme
unTamper Node.js SDK
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
canonicalizeandnode-fetch
Installation
npm install untamper-sdkQuick 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 | RETRYINGclient.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
sequenceNumberascending - Recomputes each hash using JCS canonicalization (RFC 8785) + SHA-256
- Verifies each ECDSA signature
- Checks that
previousHashlinks 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 ofcomputeRecordHash)signature— base64-encoded ECDSA signaturepublicKey— 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()).logsError 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 installCommands
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-checkRequirements
- Node.js 16+
- TypeScript 4+ (for TypeScript projects)
License
MIT — see LICENSE
Support
- Documentation: https://untamper.com/docs
- Issues: GitHub Issues
- Email: [email protected]
