@boundbook/sdk
v0.1.0
Published
TypeScript SDK for the BoundBook API — ATF-compliant bound book management with on-chain anchoring and zero-knowledge proofs. Dual-format (ESM + CJS), fully typed.
Downloads
186
Readme
@boundbook/sdk
TypeScript SDK for the BoundBook API — ATF-compliant bound book management with blockchain anchoring and zero-knowledge proofs.
Dual-format (ESM + CommonJS), zero dependencies, fully typed.
Install
npm install @boundbook/sdkQuick Start
import { BoundBookClient } from '@boundbook/sdk';
const client = new BoundBookClient({
baseUrl: 'https://api.boundbook.example.com/api/v1',
getToken: () => getAuthToken(), // async or sync
});
// Create an organization
const org = await client.organizations.create({
name: 'My FFL',
});
// Add a bound book entry
const entry = await client.boundBook.create({
locationId: 1,
manufacturer: 'Glock',
model: '19',
serialNumber: 'ABC123',
type: 'pistol',
caliberOrGauge: '9mm',
});Configuration
interface ClientConfig {
baseUrl: string; // API base URL (trailing slashes stripped)
getToken?: () => string | Promise<string>; // Auth token provider
fetch?: typeof globalThis.fetch; // Custom fetch implementation
}The fetch option is useful for testing (mock fetch) or environments without a global fetch.
API Reference
Health
await client.health.check();
// => { status: 'ok' | 'degraded' }Auth
await client.auth.sync(); // POST /auth/sync — sync user from identity provider
await client.auth.me(); // GET /auth/me — current user with tenant/locationOrganizations
await client.organizations.create({ name, ein? });
await client.organizations.get(); // current tenant
await client.organizations.getById(id); // by IDLocations
await client.locations.create({ name, address1, city, state, zipCode, fflNumber, fflType, licenseExpiry, address2?, phone? });
await client.locations.list();
await client.locations.get(id);Users
// Same-org scoped profile lookup. Returns the users row + membership
// link for a member of the caller's org; a cross-org or missing
// target returns 404. Role / permission edits go through the
// members API, not this endpoint.
await client.users.get(userId);Bound Book (Entries)
// CRUD
await client.boundBook.create({ locationId, manufacturer, model, serialNumber, type, caliberOrGauge, ... });
await client.boundBook.get(entryId);
await client.boundBook.list(locationId);
await client.boundBook.search(locationId, query);
// Status & compliance
await client.boundBook.updateStatus(entryId, { status: 'acquired' });
await client.boundBook.validate(entryId);
// => { valid: boolean, missingFields: string[], warnings: string[] }
await client.boundBook.compliance(locationId);
// => { totalEntries, inInventory, disposed, draft, incompleteEntries, overdueAcquisitions, overdueDispositions }
await client.boundBook.overdue(locationId);
// => OverdueEntry[]Acquisitions
await client.acquisitions.create({ entryId, acquisitionType, dateAcquired, sourceName, ... });
await client.acquisitions.list(entryId);Dispositions
await client.dispositions.create({ entryId, dispositionType, dateDisposed, transfereeName, ... });
await client.dispositions.list(entryId);Documents
// Upload (multipart form data)
const file = new File([buffer], 'form.pdf', { type: 'application/pdf' });
await client.documents.upload(file, { documentType: 'form_4473', entryId: 1, notes: 'Signed copy' });
await client.documents.list(entryId);
await client.documents.get(documentId);
await client.documents.download(documentId); // => { url, expiresIn }
await client.documents.delete(documentId);Manufacturers
await client.manufacturers.create({ name: 'Glock' });
await client.manufacturers.list();
await client.manufacturers.createModel(manufacturerId, { name: 'G19' });
await client.manufacturers.listModels(manufacturerId);Audit
await client.audit.query({ action?, start?, end?, limit?, offset? });
await client.audit.getByEntry(entryId);
await client.audit.exportJson({ action?, start?, end?, limit?, offset? });
await client.audit.exportCsv({ action?, start?, end?, limit?, offset? });Export
client.export.entriesCsvUrl(locationId);
// => '/export/entries/<locationId>/csv' (path you fetch with the auth header)
await client.export.exportOutOfBusinessRecords(locationId);
// => { location, entries, generatedAt }
// A&D PDF export history (the dashboard's /print page consumes these).
await client.export.listAdbookExports({ locationId?, limit?, offset? });
client.export.adbookPdfRedownloadUrl(exportId);Per-entry chain-of-custody data is reachable via
boundBook.get(id),audit.getByEntry(id),acquisitions.list(id),dispositions.list(id). The earlierexport.trace(entryId)bundle resource was removed — nothing in the dashboard consumed it and the response shape had drifted from the API.
Chain (Blockchain Anchoring)
await client.chain.anchor({ entryId, locationHash });
await client.chain.anchorBatch({ entryIds: [1, 2, 3], locationHash });
// => { stateRoot, batchId, txHash, anchorCount }
await client.chain.attest({ locationHash, dataHash });
await client.chain.verify(dataHash);
// => { committed: boolean, anchor: ChainAnchor | null }FastBound
FB uses HTTP Basic auth with a per-account API key (FAQ at
fastbound.com/faq/authentication/). FastBound's API model is one
FFL per account, so multi-location orgs run one FB account per
location/FFL. BoundBook's connection table follows that model — every
connection is keyed on locationId, and orgs with N locations hold N
rows.
// Connect a FastBound account to a specific location.
const status = await client.fastbound.connect(locationId, {
accountNumber: 'FB-9999',
apiKey: 'fb-...', // from FastBound's Settings → API page
auditUserEmail: '[email protected]',
});
// => { status: 'active', locationId, fbAccountNumber, fbUserEmail,
// connectedAt, fbOwner: { fflNumber, licenseName, premiseAddress* } }
//
// On connect, the api hits FB's /Account, caches /Account.owner on
// the connection row, and seeds empty FFL fields on the linked
// location from owner data. Refuses with 409 if the location's
// existing fflNumber doesn't match the FB account's fflNumber.
// List every FB connection across this org's locations.
await client.fastbound.listConnections();
// => Array<{ status, locationId, fbAccountNumber, fbOwner, ... }>
// Read the redacted single-location status (never returns the apiKey).
await client.fastbound.status(locationId);
// Disconnect a single location — zeros every secret column on the
// connection row and clears locations.fbStoreId so a re-connect to a
// different FB account is unambiguous.
await client.fastbound.disconnect(locationId);
// Rotate the inbound webhook HMAC secret.
await client.fastbound.rotateWebhookSecret();
// => { newSecret, previousExpiresAt }Webhook URL: each FB account's webhook is configured to
/fastbound/webhook/<locationId> so the verifier can look up the
single right secret without a fan-out check across rows.
Inbound sync (FB → BoundBook) is webhook-driven; outbound push-back
of on-chain receipts goes through PUT /Items/{id} with a fresh
BoundBook anchor tag in the note field. The mirror covers full FB
item parity (operational + FB system metadata + theft-loss +
destruction + pricing + Lightspeed IDs + notes), plus per-event
acquisition / disposition tables that link to N entries via the
new acquisition_items / disposition_items join tables. The only
FB-side fields not mirrored are attachments[] and
multipleSaleReports[] (file/PDF arrays, future plan). See
docs/fastbound-integration.md
for the full flow.
Locations
Operator-managed CRUD; FB connections attach per location. Read access
needs locations:read; create/edit/deactivate need locations:write.
await client.locations.list();
await client.locations.get(locationId);
await client.locations.create({
name: 'Branch B',
address1: '500 Oak St', city: 'Springfield', state: 'IL', zipCode: '62702',
fflNumber: '1-23-456-78-9A-99999', fflType: 'type_01',
licenseExpiry: '2030-01-01T00:00:00Z',
});
await client.locations.update(locationId, { name: 'Renamed' });
// Soft-delete (sets isActive=false) — chain anchors that reference
// this location stay valid.
await client.locations.deactivate(locationId);ZK (Zero-Knowledge Proofs)
Each proof type has prove() and verify() methods, plus a proveAndVerify() convenience method that chains both.
// Prove then verify in one call
const result = await client.zk.completeness.proveAndVerify({ entryId: 1 });
// => { valid: boolean, proofType, message, verifiedAt }
// Or separately
const proof = await client.zk.completeness.prove({ entryId: 1 });
const result = await client.zk.completeness.verify({
proof: proof.proof,
entryHash: proof.publicInputs[0],
requiredFieldCount: Number(proof.publicInputs[1]),
nonce: proof.nonce,
expiresAt: proof.expiresAt,
});Proof types with prove + verify + proveAndVerify auto-mapping:
| Sub-resource | Prove input | Purpose |
|---|---|---|
| zk.completeness | { entryId } | Entry has all required fields |
| zk.inclusion | { serialNumber, locationId } | Serial exists in location inventory |
| zk.nonTamper | { entryId } | Entry has not been tampered with |
Selective disclosure uses an explicit { prove, verify } pair (no proveAndVerify helper — the verify endpoint requires verifier-typed values that the server cannot synthesize from publicInputs):
// Prove: any combination of fields across the entry + N acquisitions + M dispositions
const bundle = await client.zk.selectiveDisclosure.prove({
entryId: 1,
entryFields: ['manufacturer', 'serialNumber'],
acquisitions: [{ acquisitionId: 5, fields: ['sourceName'] }],
dispositions: [{ dispositionId: 3, fields: ['transfereeName', 'transfereeDateOfBirth'] }],
});
// Verify: forward the bundle plus values the verifier typed
const result = await client.zk.selectiveDisclosure.verify({
bundle,
entryValues: { manufacturer: 'GLOCK', serialNumber: 'ABC123' },
acquisitionValues: [{ acquisitionId: 5, values: { sourceName: 'Acme Guns LLC' } }],
dispositionValues: [{ dispositionId: 3, values: { transfereeName: 'Jane Doe', transfereeDateOfBirth: '1990-06-15' } }],
});
result.valid; // true if every sub-proof verified AND every value matched
result.entryResult; // per-entity result with fieldMatches[]
result.acquisitionResults;
result.dispositionResults;publicVerify.checkValues(token, payload) is the unauthenticated companion for verifiers consuming a share token — same payload shape, but the response is intentionally coarse (no per-field detail) to defend PII against enumeration.
Verify-only proof types (no prove or proveAndVerify):
| Sub-resource | Purpose |
|---|---|
| zk.ownership | Verify ownership proof with public key |
| zk.trust | Verify trust chain with license hash |
Verify-only proof types (no prove or proveAndVerify):
| Sub-resource | Purpose |
|---|---|
| zk.ownership | Verify ownership proof with public key |
| zk.trust | Verify trust chain with license hash |
Cross-organization serial trace
Not on this SDK. Cross-org tracer users (LE / regulator) authenticate
through Clerk against the dashboard's (tracer) route group at
/api/trace/search and /api/trace/disclose/[entryId], not via the
per-org API. See docs/platform-admin-architecture.md for the
end-to-end picture.
Error Handling
All API errors throw BoundBookError with status helpers:
import { BoundBookError, isBoundBookError } from '@boundbook/sdk';
try {
await client.boundBook.get(999);
} catch (err) {
if (isBoundBookError(err)) {
err.status; // 404
err.statusText; // 'Not Found'
err.body; // raw response body
// Status helpers
err.isNotFound; // true
err.isUnauthorized; // status === 401
err.isForbidden; // status === 403
err.isValidationError; // status === 400
err.isRateLimited; // status === 429
// Validation errors (400) may have multiple messages
err.validationMessages; // string[]
}
}Types
All types are exported from the package root:
import type {
// Core models
Organization, User, UserMemberProfile, Location, Entry,
Acquisition, Disposition, Document, Manufacturer, Model,
// DTOs
CreateOrganizationDto, CreateLocationDto, CreateEntryDto,
CreateAcquisitionDto, CreateDispositionDto, CreateDocumentDto,
CreateManufacturerDto, CreateModelDto, UpdateStatusDto,
// Enums (const arrays + union types)
FirearmType, EntryStatus, AcquisitionType, DispositionType,
FflType, DocumentType, AnchorType, UserRole, AuditAction,
ProofType,
EntryDisclosedField, AcquisitionDisclosedField, DispositionDisclosedField,
// Other
HealthStatus, ComplianceSummary, ValidationResult, OverdueEntry,
AuditEvent, ChainAnchor, VerifyResult,
OutOfBusinessRecordsExport, BatchAnchorResult,
ZkProof, VerificationResult,
ClientConfig, ApiErrorBody,
} from '@boundbook/sdk';
// Enum arrays are also available as values for runtime validation
import { FIREARM_TYPES, ENTRY_STATUSES, USER_ROLES } from '@boundbook/sdk';Development
npm -w @boundbook/sdk run build # Build ESM + CJS + .d.ts
npm -w @boundbook/sdk run dev # Build with --watch
npm -w @boundbook/sdk run test # Run tests
npm -w @boundbook/sdk run test:watch # Run tests with --watch
npm -w @boundbook/sdk run test:coverage # Run tests with coverage