The Node SDK.
GA@tillstack/sdk-notary-node is a verifying client, not an HTTP wrapper. Everything the service returns is checked against the keys you pinned before your code sees it. Verification cannot be turned off, and a log that is not in your trust store cannot be used at all.
Install & construct with pinned trust
pnpm add @tillstack/sdk-notary-node
# or: npm install @tillstack/sdk-notary-nodeThe simplest start is the trust file the CLI writes with tilldev notary trust-init, once you have checked its fingerprints (see trust-init). parseTrustFile rejects a malformed file and trustFromFile turns it into the logs map:
import { NotaryClient, parseTrustFile, trustFromFile } from '@tillstack/sdk-notary-node'
import { readFile } from 'node:fs/promises'
const trust = parseTrustFile(await readFile('./notary-trust.json', 'utf8'), 'notary-trust.json')
const notary = new NotaryClient({
baseUrl: trust.base_url ?? 'https://notary.tilldev.dev',
token: process.env.TILLDEV_NOTARY_TOKEN, // omit to read public logs
logs: trustFromFile(trust),
})Or pass the same keys inline, camel-cased (public keys shortened here). They are all public, so they are safe to commit, unlike a token:
import { NotaryClient } from '@tillstack/sdk-notary-node'
const notary = new NotaryClient({
baseUrl: 'https://notary.tilldev.dev',
token: process.env.TILLDEV_NOTARY_TOKEN,
logs: {
'acme-releases': {
origin: 'notary.tilldev.dev/acme-releases',
logKeyName: 'tillnotary-log/acme-releases',
publicKey: 'HXNpZy1lZDI1NTE5LW1sZHNhNjUtc2hhMjU2LnYx…',
witnesses: [
{ keyName: 'tillnotary-witness/w1', publicKey: 'HXNpZy1lZDI1NTE5LW1sZHNhNjUtc2hhMjU2LnYx…' },
{ keyName: 'acme-witness/primary', publicKey: 'HXNpZy1lZDI1NTE5LW1sZHNhNjUtc2hhMjU2LnYx…' },
],
requiredCosignatures: 2,
},
},
timeoutMs: 15_000, // per request; this is the default
})logs throws a NotaryClientError before a request is made. There is no way to talk to a log the client cannot verify.The client
| Method | Returns | Notes |
|---|---|---|
submit(slug, hex | {bytesB64}) | {leafIndex, leaf, checkpoint, complete, duplicate, evidence} | Adds an entry and checks its inclusion before resolving. Resubmitting a value returns its original receipt with duplicate: true, so retries never log twice. Needs a submit token. |
getLeaf(slug, index, {allowIncomplete?}) | {leafIndex, leaf, bytesB64, contentClass, checkpoint, evidence} | Strict: throws until a checkpoint that includes the leaf meets your threshold, unless allowIncomplete. |
findLeaf(slug, hex | {bytesB64}, {allowIncomplete?}) | {leafIndex, leaf, bytesB64, contentClass, checkpoint, evidence} | null | The entry holding a value, verified and strict like getLeaf. Null only when the log has no such entry; a log you cannot read throws. |
listLeaves(slug, {before?, limit?}) | {treeSize, leaves} | Entries newest first, up to 200 per call, as served. Not verified on their own: getLeaf and findLeaf prove one, the mirror checks them all. |
getCheckpoint(slug, {allowIncomplete?}) | The verified checkpoint | The newest witness-complete checkpoint; allowIncomplete returns the newest head instead. |
verifyConsistencyFrom(slug, {treeSize, rootHash}, {allowIncomplete?}) | The new verified checkpoint | Proves the log today extends a head you recorded. The heartbeat below is built on it. |
getNote(slug, {allowIncomplete?}) | {text, checkpoint} | The checkpoint as a signed note, byte for byte, verified first. |
getRefusals(slug, {before?, limit?}) | {refusals, rejected, nextBefore} | Witness refusals, newest first, each verified against your pinned witness keys. See below. |
exportEvidence(slug, index) | An evidence bundle | Witness-complete only, so it verifies offline. See Evidence bundles. |
And standalone functions, which need no client:
| Function | Notes |
|---|---|
verifyEvidence(bundle, trust) | Verifies an evidence bundle with no network; works air-gapped. |
verifyCheckpointRecord(record, trust) | Verifies a stored checkpoint (the shape in evidence bundles and mirrors) with no network; complete says whether your threshold is met. |
parseTrustFile(json, source?) | Throws on invalid JSON or a log missing origin, log_key_name or public_key. |
trustFromFile(file) | The logs map for the constructor; index it by slug for verifyEvidence. |
keyFingerprint(publicKeyB64) | The fingerprint the CLI and dashboard print, for checking keys out of band. |
// a digest you computed; nothing but the hash leaves your process
const receipt = await notary.submit('acme-releases', sha256Hex)
receipt.leafIndex // 5
receipt.checkpoint // verified: { origin, treeSize, rootHash, timestamp, cosignatures, complete, … }
receipt.complete // true once your pinned cosignature threshold is met
receipt.duplicate // true when the value was already logged: this is its original receipt
receipt.evidence // an evidence bundle for this entry
// publish the bytes themselves (4096 at most; they become publicly readable)
const attested = await notary.submit('acme-releases', {
bytesB64: Buffer.from(sbomSummary).toString('base64'),
})// strict: resolves only when a checkpoint that includes the leaf meets your threshold
const leaf = await notary.getLeaf('acme-releases', 5)
// → { leafIndex, leaf, bytesB64, contentClass, checkpoint, evidence }
// right after a submit, before the witnesses' next pass
const early = await notary.getLeaf('acme-releases', 8, { allowIncomplete: true })
early.checkpoint.complete // false
// the entry holding a value (hex, or { bytesB64 }), verified the same way
const found = await notary.findLeaf('acme-releases', sha256Hex)
found?.leafIndex // 5; null when the log holds no such value
// the newest witness-complete checkpoint, or the newest head with allowIncomplete
const head = await notary.getCheckpoint('acme-releases')
const newest = await notary.getCheckpoint('acme-releases', { allowIncomplete: true })
// prove the log still extends a head you recorded earlier
const now = await notary.verifyConsistencyFrom('acme-releases', {
treeSize: 4,
rootHash: 'a1d55691a4764f1c797f8849de205858215b2113e6f0dca650bc5c85f428a743',
})
// the checkpoint as a signed note, byte for byte, after verification
const { text, checkpoint } = await notary.getNote('acme-releases')
// a witness-complete evidence bundle
const bundle = await notary.exportEvidence('acme-releases', 5)import { verifyEvidence } from '@tillstack/sdk-notary-node'
// no client and no network: the same pinned trust you give the client for this log
const verified = verifyEvidence(bundle, trustFromFile(trust)['acme-releases'])
// returns the verified checkpoint; throws NotaryVerificationError on any failureimport { keyFingerprint } from '@tillstack/sdk-notary-node'
keyFingerprint(trust.logs['acme-releases'].public_key)
// '1388 3e7f fc10 01f9 32c0 e322 13f6 aba7' (the same as the CLI and the dashboard)Reading witness refusals
A refusal is a witness’s signed statement that the log showed it a history that does not extend what it had cosigned (what that means). getRefusals checks each one against the witness key you pinned. Statements it cannot check, from a witness you did not pin or with a bad signature, go in rejected and never in refusals:
// newest first, 100 at most per page
let before: number | undefined
do {
const page = await notary.getRefusals('acme-releases', { before, limit: 100 })
for (const r of page.refusals) {
// verified against the witness key you pinned
console.error(`${r.witness} refused: ${r.reason} (${r.fromSize} ${r.fromRoot} → ${r.toSize} ${r.toRoot})`)
}
for (const x of page.rejected) {
// listed by the service, but not verifiable with your pins: proves nothing either way
console.warn('unverifiable refusal', x.id, x.witness, x.reason)
}
before = page.nextBefore ?? undefined
} while (before !== undefined)Keep the verified statements: each one verifies against the witness key with or without TillNotary.
Error classes, and retries
| Error | Means | React |
|---|---|---|
NotaryClientError | The request did not succeed: network failure, timeout, a refused or rate-limited request, an unpinned slug, bad input. Carries status, code, requestId and retryAfterMs. | Handle like any API error: wait, fix the input or the token. |
NotaryVerificationError | The service answered, and the answer failed verification: a bad signature, a root that does not recompute, a history that does not extend yours. | Never catch and continue. Keep what you were checking against, alert a person, stop trusting new output from that log. |
NotaryIncompleteError | A NotaryVerificationError from strict reads: everything checked out, but no head your pinned witnesses cosigned covers it yet. Nothing is wrong. | Check for it before NotaryVerificationError. Retry after the witnesses’ next pass, or pass allowIncomplete. |
import { NotaryClientError, NotaryIncompleteError, NotaryVerificationError } from '@tillstack/sdk-notary-node'
try {
await notary.getLeaf('acme-releases', 9)
} catch (err) {
if (err instanceof NotaryIncompleteError) {
// verified, but your witnesses have not cosigned a head that covers it yet
return retryAfterWitnessPass()
}
if (err instanceof NotaryVerificationError) {
// not retryable and not ignorable: a signature failed, a root did not
// recompute, or the history does not extend what you hold
throw err
}
if (err instanceof NotaryClientError) {
err.status // 429
err.code // 'RATE_LIMITED'
err.requestId // quote this to support
err.retryAfterMs // how long the service asked you to wait, when it said
}
throw err
}Reads and submissions are retried on network errors and on 429, 502, 503 and 504, with jittered backoff, and wait as long as the service’s Retry-After asks. A wait longer than maxDelayMs, such as a daily quota, is thrown at once with retryAfterMs set rather than slept through. Admin changes are never retried. After the last attempt the message ends with “(after N attempts)”.
const patient = new NotaryClient({
baseUrl: 'https://notary.tilldev.dev',
token: process.env.TILLDEV_NOTARY_TOKEN,
logs: trustFromFile(trust),
retry: {
retries: 5, // attempts after the first; 0 turns retries off (default 3)
maxDelayMs: 10_000, // longest single wait (default 30000)
onRetry: ({ attempt, delayMs, reason }) => console.warn(`${reason}; retry ${attempt} in ${delayMs} ms`),
},
})NotaryVerificationError in production is the system doing its job, the same class of event as a witness refusal. Code that swallows it turns detection into decoration.Managing logs and witnesses
NotaryAdminClient takes an admin token and covers what the dashboard does: listLogs, getLog, createLog, updateLog, freezeLog, attachWitness, detachWitness, listWitnesses, registerWitness and setWitnessActive. Its reads retry like the client’s; its writes never do.
import { NotaryAdminClient } from '@tillstack/sdk-notary-node'
const admin = new NotaryAdminClient({
baseUrl: 'https://notary.tilldev.dev',
token: process.env.TILLDEV_NOTARY_ADMIN_TOKEN, // admin scope
})
const created = await admin.createLog({
slug: 'acme-releases',
description: 'Release artifact digests',
maxLeavesPerDay: 5000, // new entries per rolling 24 hours; 50,000 when omitted
})
created.witnesses // ['tillnotary-witness/w1']: the platform witnesses, unless you pass witnesses
await admin.registerWitness({
name: 'acme-witness/primary',
suite: 'sig-ed25519-mldsa65-sha256.v1',
publicKey: 'HXNpZy1lZDI1NTE5LW1sZHNhNjUtc2hhMjU2LnYx…',
custodyNote: 'Acme security team',
})
await admin.attachWitness('acme-releases', 'acme-witness/primary')
await admin.updateLog('acme-releases', { witnessThreshold: 2 })
// retiring a witness reports every log that can no longer reach its threshold
const { stranded } = await admin.setWitnessActive('acme-witness/primary', false)
for (const s of stranded) console.warn(`${s.slug} needs ${s.required} cosignatures, has ${s.active} active witnesses`)Running a witness in code
The CLI’s witness-node run is built on these exports, so you can run a witness inside your own service instead. @tillstack/sdk-notary-node/witness-state is Node-only and reads the key file the CLI makes (loadWitnessKey, writeWitnessKey, witnessIdentity) and keeps state in a file written atomically with mode 0600. Call witnessPass on a timer; one process per state file.
import { witnessPass, witnessTransport } from '@tillstack/sdk-notary-node'
import { fileWitnessState, loadWitnessKey } from '@tillstack/sdk-notary-node/witness-state'
const signer = loadWitnessKey('./witness.key') // made by: tilldev notary witness-node keygen
const outcomes = await witnessPass({
signer,
trust: [],
discover: true, // cosign every log this witness is attached to
fetchJson: witnessTransport({ baseUrl: 'https://notary.tilldev.dev', signer }),
state: fileWitnessState('./witness-state.json'), // keep with the key; losing it resets what was cosigned
log: (level, message) => console.error(level, message),
})
for (const o of outcomes) {
if (o.action === 'refused' || o.action === 'alarm') {
// page someone: the log showed this witness a history it would not cosign
console.error('WITNESS', o.action, o.origin, o.reason)
}
}Each outcome is one of cosigned, already-cosigned, bootstrap-cosigned, refused, alarm or skipped. What each means, and what to do about a refusal or an alarm, is in Witnesses.
The periodic consistency heartbeat
The most valuable thing to automate: record the log’s head, and on a schedule prove the log still extends it. It is your own check on top of the witnesses, and public logs need no token for it:
// consistency-heartbeat.ts: run every 15 minutes from whatever scheduler you have
import { readFile, writeFile } from 'node:fs/promises'
import { NotaryClient, NotaryVerificationError, parseTrustFile, trustFromFile } from '@tillstack/sdk-notary-node'
const HEAD_FILE = 'notary-head.json' // durable and backed up
const trust = parseTrustFile(await readFile('./notary-trust.json', 'utf8'))
const notary = new NotaryClient({ baseUrl: 'https://notary.tilldev.dev', logs: trustFromFile(trust) })
export async function heartbeat(): Promise<void> {
const prev = JSON.parse(await readFile(HEAD_FILE, 'utf8')) as { treeSize: number; rootHash: string }
let head
try {
// today's head is signed and witness-complete, and extends the one we recorded
head = await notary.verifyConsistencyFrom('acme-releases', prev)
} catch (err) {
if (err instanceof NotaryVerificationError) {
// The log showed a history that does not extend the one we hold.
// Keep HEAD_FILE (it is evidence), page a human, stop accepting receipts.
console.error('NOTARY CONSISTENCY FAILED', err.message)
process.exitCode = 1
return
}
// a network error, or the service busy after retries: keep the old head, try next tick
console.warn('notary heartbeat skipped:', String(err))
return
}
// advance the recorded head only after the proof verified
await writeFile(HEAD_FILE, JSON.stringify({ treeSize: head.treeSize, rootHash: head.rootHash }))
}Seed notary-head.json from a receipt you trust, and keep it somewhere durable and backed up: your next proof is anchored to it and, if the worst happened, it is one half of a non-repudiable equivocation proof.
Keeping a verified mirror
A mirror is a full copy of a log that you hold. mirrorPass downloads the entries added since its last pass, hashes each into the log’s Merkle tree, and keeps them only if they rebuild exactly the root the log signed. If the service ever loses, withholds or rewrites an entry, your copy and its signed heads still show what was logged. @tillstack/sdk-notary-node/mirror is Node-only; the CLI’s notary mirror runs the same code.
import { NotaryClient, parseTrustFile, trustFromFile } from '@tillstack/sdk-notary-node'
import { MirrorAlarm, mirrorPass, verifyMirror } from '@tillstack/sdk-notary-node/mirror'
const trust = trustFromFile(parseTrustFile(readFileSync('notary-trust.json', 'utf8')))
const notary = new NotaryClient({ baseUrl: 'https://notary.tilldev.dev', logs: trust })
const dir = '/var/lib/notary-mirror/acme-releases'
try {
const pass = await mirrorPass(notary, 'acme-releases', dir)
// { origin, fromSize: 8, treeSize: 10, appended: 2, rootHash: 'bc62d10e…', complete: true }
} catch (err) {
if (err instanceof MirrorAlarm) {
err.record.reason // 'shrank' | 'forked' | 'rewritten' | 'entries-mismatch'
err.record.held // the signed head the mirror held
err.record.served // the signed head that contradicts it
await page(err.message) // the record is also appended to alarms.jsonl
}
throw err
}
// any time, no network: the log's signature on the held head, and every stored entry rebuilding its root
await verifyMirror(dir, trust['acme-releases'])Like getCheckpoint, a pass follows witness-complete heads unless you pass allowIncomplete, which checks each new head as soon as the log signs it. A lock file allows one writer per directory, and a pass that stops midway keeps nothing it had not verified. MirrorAlarm extends NotaryVerificationError; for a shrink, a fork or a rewrite its record holds two log signatures that cannot both be honest, which anyone can check with verifyCheckpointRecord.
The objects this SDK verifies are specified in Checkpoints & signatures and Evidence bundles. The CLI gives the same guarantees from a shell.