/** * Attest Integrations client for TypeScript and JavaScript. * * One dependency-free file. Runs anywhere with fetch and Web Crypto: Node 18+, * Deno, Bun, Cloudflare Workers, Vercel functions. * * const attest = new Attest({ apiKey: process.env.ATTEST_API_KEY! }); * await attest.organizations.upsert({ externalRef: "hotel-42", name: "Pousada AysĂș" }); * await attest.events.record({ organization: "hotel-42", type: "statement.closed", data: statement }); * await attest.snapshots.record({ organization: "hotel-42", stream: "hotel.daily", state, metrics }); * await attest.credentials.issue({ organization: "hotel-42", template: "membership", title: "Owner portal access", recipient, fields }); * await attest.certificates.create({ organization: "hotel-42", title, subjectName, statement, asOf, sections, signers }); * * Docs: https://attest.datahubz.com/docs/integrations */ export const VERSION = "1.1.0"; // ---- types -------------------------------------------------------------------- export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue | undefined }; export type Metadata = Record; export interface Wallet { address: string; network: "Horizen"; chainId: number | null; explorerUrl: string | null; } export interface Organization { id: string; object: "organization"; externalRef: string | null; name: string; wallet: Wallet | null; /** The organization's public page, when public organization pages are enabled; null until then. */ publicUrl: string | null; /** Excluded organizations are hidden from the transparency pages and take no new records. */ excluded: boolean; createdAt: string; } export interface Anchor { network: "Horizen"; chainId: number | null; value: string; txHash: string; blockNumber: string | null; from: string | null; anchoredAt: string | null; explorerUrl: string | null; } export interface Event { id: string; object: "event"; organization: { id: string; externalRef: string | null }; type: string; subject: string | null; occurredAt: string; digest: string; metadata: Metadata | null; status: "pending" | "anchored" | "failed"; anchor: Anchor | null; error: string | null; verifyUrl: string; createdAt: string; } export type CheckOp = "eq" | "lte" | "gte"; export interface Metric { /** snake_case, up to 40 characters. Public. */ name: string; /** A whole number from 0 to 2^64-1 (use cents for money). Never stored, never revealed. */ value: number | bigint | string; /** Optional public check; the proof shows whether it holds, without revealing the value. */ check?: { op: CheckOp; value: number | bigint | string }; } export interface Snapshot { id: string; object: "snapshot"; organization: { id: string; externalRef: string | null }; stream: string; day: string; stateDigest: string; metrics: { name: string; check: { op: CheckOp; value: string } | null; result: boolean }[]; commitment: string; previousCommitment: string; previousId: string | null; anchor: Anchor | null; anchorStatus: "pending" | "anchored" | "failed"; proof: { system: "groth16"; circuit: "state_attestation"; status: "pending" | "submitted" | "verified" | "failed"; zkVerify: { txHash: string; explorerUrl: string | null } | null; error: string | null; publicSignals: string[]; proof: unknown; }; verifyUrl: string; /** The organization's public page, when public organization pages are enabled; null until then. */ publicUrl: string | null; createdAt: string; /** Returned only when the snapshot is created: with the metric values, it opens the commitment to an auditor. Store it. */ opening?: { salt: string }; } export type CardTemplate = "training" | "supplier" | "membership" | "employment" | "custom"; export type CardState = "active" | "suspended" | "revoked"; /** A Proof Card (W3C Verifiable Credential) one of your organizations issued. */ export interface Credential { id: string; /** The credential's own id (urn:uuid:...), inside the signed credential. */ credentialId: string; template: CardTemplate; title: string; state: CardState; stateReason: string | null; stateChangedAt: string | null; issuer: { orgId: string; name: string; did: string }; recipient: { email: string; name: string | null }; subject: Record; /** Whether the recipient accepted it into their wallet. */ claimed: boolean; validFrom: string; validUntil: string | null; createdAt: string; /** Anchored on Horizen from the organization's wallet: null until the transaction confirms. */ anchor: { chainId: number | null; txHash: string | null; blockNumber: string | null; anchoredAt: string | null; explorerUrl: string | null } | null; /** Where the recipient accepts the card (returned when it's issued and when read). */ claimUrl?: string; } export type CertificateStatus = "awaiting_signatures" | "issuing" | "issued" | "revoked"; export interface CertificateSection { heading: string; body?: string; items?: { label: string; detail?: string; ref?: string; result?: string }[]; } /** A certificate about one of your organizations, issued by the organization DataHubz assigned to your platform. */ export interface Certificate { id: string; object: "certificate"; status: CertificateStatus; /** The public certificate on Attest. Link to it; the certificate is published there only. */ url: string; /** Where the signers sign, while it's awaiting signatures. */ signUrl?: string; issuer: { id: string; did: string; name: string; domain: string | null }; language: "en" | "es"; title: string; subject: { name: string; domain: string | null }; statement: string; asOf: string; validUntil: string | null; sections: CertificateSection[]; reference: string | null; attachments: { name: string; sha256: string }[]; contentSha256: string; signers: { name: string; role: string | null; signedAt: string | null }[]; issuedAt: string | null; pdf: { sha256: string; url: string } | null; credential: { format: "vc+jwt"; url: string } | null; anchor: { txHash: string; chainId: number | null; blockNumber: number | null; from: string | null; anchoredAt: string; value: string | null } | null; revocation: { revokedAt: string; reason: string | null; txHash: string | null } | null; supersedes: string | null; findingsClosure: { statement: string; circuit: string; commitment: string; verificationKey: string; zkVerify: { status: string | null; txHash: string | null }; /** Returned once, when the certificate is created: with the finding list, it opens the commitment to an auditor. Store it. */ salt?: string; } | null; createdAt: string; } export interface List { object: "list"; data: T[]; nextCursor: string | null; } export interface Partner { id: string; object: "partner"; name: string; sponsored: boolean; webhook: { url: string } | null; organizations: number; key: { id: string; label: string | null; prefix: string }; } export type WebhookType = "event.anchored" | "event.failed" | "snapshot.anchored" | "snapshot.verified" | "snapshot.failed" | "ping"; export interface WebhookEvent> { id: string; type: WebhookType; createdAt: string; data: T; } export interface AttestOptions { /** An integration key, `ik_live_...`. Server-side only. */ apiKey: string; /** Defaults to https://attest.datahubz.com */ baseUrl?: string; /** Per-request timeout. Default 30 s (a snapshot is proven before the response). */ timeoutMs?: number; /** Retries for network errors, 429 and 5xx. Default 3. Safe: every write is idempotent. */ maxRetries?: number; fetch?: typeof fetch; } // ---- errors ------------------------------------------------------------------- export class AttestError extends Error { constructor( message: string, public readonly status: number, public readonly code: string ) { super(message); this.name = "AttestError"; } } // ---- helpers ------------------------------------------------------------------ /** Canonical JSON: object keys sorted, undefined dropped. The exact bytes Attest fingerprints. */ export function canonicalJson(v: unknown): string { if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`; if (v && typeof v === "object") { return `{${Object.keys(v) .sort() .filter((k) => (v as Record)[k] !== undefined) .map((k) => `${JSON.stringify(k)}:${canonicalJson((v as Record)[k])}`) .join(",")}}`; } return JSON.stringify(v); } const hex = (buf: ArrayBuffer) => Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join(""); /** * The digest Attest computes for `data`: sha256 of its canonical JSON. Send it * as `digest` instead of `data` to keep the data itself on your side; either * way the event carries the same fingerprint. */ export async function digest(data: JsonValue): Promise { return sha256(new TextEncoder().encode(canonicalJson(data))); } /** sha256 of raw bytes (a file, a PDF, a string's UTF-8 bytes), for `digest`. */ export async function digestBytes(data: Uint8Array | ArrayBuffer | string): Promise { return sha256(typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data instanceof ArrayBuffer ? data : data.slice().buffer)); } async function sha256(bytes: Uint8Array): Promise { return hex(await crypto.subtle.digest("SHA-256", bytes as unknown as Parameters[1])); } const randomKey = () => { const b = crypto.getRandomValues(new Uint8Array(16)); return `sdk_${hex(b.buffer)}`; }; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const num = (v: number | bigint | string) => (typeof v === "bigint" ? v.toString() : v); // ---- client ------------------------------------------------------------------- export class Attest { private readonly apiKey: string; private readonly baseUrl: string; private readonly timeoutMs: number; private readonly maxRetries: number; private readonly fetchImpl: typeof fetch; constructor(opts: AttestOptions) { if (!opts?.apiKey || !opts.apiKey.startsWith("ik_")) throw new Error("Attest: apiKey must be an integration key (ik_live_...)."); this.apiKey = opts.apiKey.trim(); this.baseUrl = (opts.baseUrl ?? "https://attest.datahubz.com").replace(/\/+$/, ""); this.timeoutMs = opts.timeoutMs ?? 30_000; this.maxRetries = opts.maxRetries ?? 3; this.fetchImpl = opts.fetch ?? fetch; } /** The partner this key belongs to. Handy as a health check. */ partner(): Promise { return this.request("GET", "/api/v1/partner"); } readonly organizations = { /** Register an organization you serve, or update its name. Idempotent on `externalRef`. Creates its own wallet. */ upsert: (input: { externalRef: string; name: string; website?: string }): Promise => this.request("POST", "/api/v1/organizations", input), /** By Attest id (org_...) or your externalRef. */ get: (ref: string): Promise => this.request("GET", `/api/v1/organizations/${encodeURIComponent(ref)}`), list: (params: { limit?: number; cursor?: string } = {}): Promise> => this.request("GET", `/api/v1/organizations${query(params)}`), /** Exclude an organization (a test one, a customer who left), or bring it back. What is already on chain stays. */ setExcluded: (ref: string, excluded: boolean): Promise => this.request("PATCH", `/api/v1/organizations/${encodeURIComponent(ref)}`, { excluded }), }; readonly events = { /** * Record an event. It is anchored on Horizen in its own transaction from the * organization's wallet, usually within seconds. Send `data` (fingerprinted, * not kept) or `digest`. An idempotency key is generated if you don't pass one; * pass your own (e.g. `statement-123-closed`) to make replays across restarts safe. */ record: (input: { organization: string; type: string; subject?: string; occurredAt?: string | Date; data?: JsonValue; digest?: string; metadata?: Metadata; idempotencyKey?: string; }): Promise => { const { idempotencyKey, occurredAt, ...rest } = input; return this.request("POST", "/api/v1/events", { ...rest, ...(occurredAt ? { occurredAt: occurredAt instanceof Date ? occurredAt.toISOString() : occurredAt } : {}) }, idempotencyKey ?? randomKey()); }, get: (id: string): Promise => this.request("GET", `/api/v1/events/${encodeURIComponent(id)}`), list: (params: { organization?: string; type?: string; status?: Event["status"]; limit?: number; cursor?: string } = {}): Promise> => this.request("GET", `/api/v1/events${query(params)}`), /** Poll until the event is anchored (or failed). Webhooks are the better fit in production. */ waitForAnchor: async (id: string, opts: { timeoutMs?: number; intervalMs?: number } = {}): Promise => { const until = Date.now() + (opts.timeoutMs ?? 120_000); for (;;) { const e = await this.events.get(id); if (e.status !== "pending" || Date.now() > until) return e; await sleep(opts.intervalMs ?? 3000); } }, }; readonly snapshots = { /** * Record an organization's state for a day (default: today, UTC). Attest * commits to it, chains it to the previous day, proves the checks in zero * knowledge, verifies the proof on zkVerify and anchors the commitment on * Horizen. One per organization, stream and day; sending the same day again * with the same state is a no-op. */ record: (input: { organization: string; stream: string; day?: string | Date; state?: JsonValue; digest?: string; metrics: Metric[]; metadata?: Metadata }): Promise => { const { day, metrics, ...rest } = input; return this.request("POST", "/api/v1/snapshots", { ...rest, ...(day ? { day: day instanceof Date ? day.toISOString().slice(0, 10) : day } : {}), metrics: metrics.map((m) => ({ name: m.name, value: num(m.value), ...(m.check ? { check: { op: m.check.op, value: num(m.check.value) } } : {}) })), }); }, get: (id: string): Promise => this.request("GET", `/api/v1/snapshots/${encodeURIComponent(id)}`), list: (params: { organization?: string; stream?: string; limit?: number; cursor?: string } = {}): Promise> => this.request("GET", `/api/v1/snapshots${query(params)}`), }; readonly credentials = { /** * Issue a Proof Card from one of your organizations to a person. It is signed with the organization's own key, * and its issuance is anchored on Horizen from the organization's wallet. The recipient gets an email to accept * it (`notify: false` skips it). Idempotent: an idempotency key is generated if you don't pass one; pass your own * (e.g. `access-${membership.id}`) so a retry after a restart returns the same card. */ issue: (input: { organization: string; template: CardTemplate; title: string; recipient: { email: string; name?: string }; /** Template fields: training `completedOn`, supplier `approvedOn`, membership `memberSince`, employment `startDate` (YYYY-MM-DD); custom: any flat fields. */ fields?: Record; description?: string; validFrom?: string | Date; /** null: no end date. Omitted: the template's usual validity. */ validUntil?: string | Date | null; includeEmail?: boolean; notify?: boolean; idempotencyKey?: string; }): Promise => { const { idempotencyKey, validFrom, validUntil, ...rest } = input; const date = (d: string | Date) => (d instanceof Date ? d.toISOString() : d); return this.request( "POST", "/api/v1/credentials", { ...rest, ...(validFrom ? { validFrom: date(validFrom) } : {}), ...(validUntil !== undefined ? { validUntil: validUntil === null ? null : date(validUntil) } : {}) }, idempotencyKey ?? randomKey() ); }, get: (id: string): Promise; sdJwt: string }> => this.request("GET", `/api/v1/credentials/${encodeURIComponent(id)}`), list: async (params: { organization?: string; limit?: number } = {}): Promise => (await this.request<{ credentials: Credential[] }>("GET", `/api/v1/credentials${query(params)}`)).credentials, /** Suspend (temporarily), reinstate, or revoke (permanently). Every change is anchored and the holder is emailed. */ setStatus: (id: string, action: "suspend" | "reinstate" | "revoke", opts: { reason?: string; message?: string } = {}): Promise => this.request("POST", `/api/v1/credentials/${encodeURIComponent(id)}/status`, { action, ...opts }), }; readonly certificates = { /** * Create a certificate about one of your organizations. It's issued by the organization DataHubz assigned to your * platform once the people in `signers` sign it with their passkeys (at `signUrl`), then signed as a W3C * Verifiable Credential, rendered as a PDF and anchored on Horizen. The certificate is published on Attest only: * link to `url`. Idempotent like `credentials.issue`; a replay returns the certificate without the findings salt. */ create: (input: { organization: string; title: string; subjectName: string; subjectDomain?: string; statement: string; asOf: string | Date; validUntil?: string | Date; sections: CertificateSection[]; reference?: string; attachments?: { name: string; sha256: string }[]; signers: { email: string; name: string; role?: string }[]; language?: "en" | "es"; supersedes?: string; /** A zero-knowledge proof, verified on zkVerify, that every finding in the set is closed, without revealing them. */ findingsClosure?: { items: { id: string; severity: "info" | "low" | "medium" | "high" | "critical"; closed: true }[] }; idempotencyKey?: string; }): Promise => { const { idempotencyKey, asOf, validUntil, ...rest } = input; const date = (d: string | Date) => (d instanceof Date ? d.toISOString() : d); return this.request("POST", "/api/v1/certificates", { ...rest, asOf: date(asOf), ...(validUntil ? { validUntil: date(validUntil) } : {}) }, idempotencyKey ?? randomKey()); }, get: (id: string): Promise => this.request("GET", `/api/v1/certificates/${encodeURIComponent(id)}`), list: async (params: { organization?: string; limit?: number } = {}): Promise => (await this.request<{ certificates: Certificate[] }>("GET", `/api/v1/certificates${query(params)}`)).certificates, }; private async request(method: "GET" | "POST" | "PATCH", path: string, body?: unknown, idempotencyKey?: string): Promise { const payload = body === undefined ? undefined : JSON.stringify(body); for (let attempt = 0; ; attempt++) { let res: Response; try { res = await this.fetchImpl(`${this.baseUrl}${path}`, { method, headers: { Authorization: `Bearer ${this.apiKey}`, Accept: "application/json", "User-Agent": `attest-typescript/${VERSION}`, ...(payload ? { "Content-Type": "application/json" } : {}), ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}), }, body: payload, signal: AbortSignal.timeout(this.timeoutMs), }); } catch (err) { if (attempt < this.maxRetries) { await sleep(backoff(attempt)); continue; } throw new AttestError(`Attest is unreachable: ${err instanceof Error ? err.message : String(err)}`, 0, "network_error"); } if ((res.status === 429 || res.status >= 500) && attempt < this.maxRetries) { const retryAfter = Number(res.headers.get("retry-after")); await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter, 60) * 1000 : backoff(attempt)); continue; } const data = (await res.json().catch(() => ({}))) as { error?: { code?: string; message?: string } }; if (!res.ok) throw new AttestError(data.error?.message ?? `HTTP ${res.status}`, res.status, data.error?.code ?? "http_error"); return data as T; } } // ---- webhooks ----------------------------------------------------------------- /** * Verify a webhook and return its payload. Pass the RAW request body (not * parsed JSON), the `Attest-Signature` header, and your signing secret. * Throws if the signature is wrong or older than `toleranceSeconds`. */ static async verifyWebhook>(rawBody: string, signatureHeader: string | null, secret: string, toleranceSeconds = 300): Promise> { const parts = Object.fromEntries((signatureHeader ?? "").split(",").map((p) => p.trim().split("=") as [string, string])); const t = Number(parts.t); if (!parts.v1 || !Number.isFinite(t)) throw new AttestError("Missing or malformed Attest-Signature header", 400, "invalid_signature"); if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) throw new AttestError("Webhook timestamp is outside the tolerance", 400, "invalid_signature"); const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const expected = hex(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${t}.${rawBody}`))); if (!timingSafeEqual(expected, parts.v1)) throw new AttestError("Webhook signature does not match", 400, "invalid_signature"); return JSON.parse(rawBody) as WebhookEvent; } } function backoff(attempt: number) { return Math.min(8000, 500 * 2 ** attempt) + Math.floor(Math.random() * 250); } function timingSafeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); return diff === 0; } function query(params: Record): string { const q = Object.entries(params).filter(([, v]) => v !== undefined && v !== ""); return q.length ? `?${new URLSearchParams(q.map(([k, v]) => [k, String(v)] as [string, string])).toString()}` : ""; } export default Attest;