@dtfoundry/foundry-sdk
v0.1.0
Published
Typed client for the Digital Trust Foundry Platform partner API
Downloads
164
Keywords
Readme
@dtfoundry/foundry-sdk
Typed JavaScript client for the Digital Trust Foundry Platform partner API.
It is a transport layer over one tenant's Platform instance: auth header, envelope normalization, typed errors, retries, idempotency and the async-completion loop. It resolves no tenants, stores no credentials and caches no business objects.
npm install @dtfoundry/foundry-sdkRequires Node 20 or newer. ESM only.
Getting started
import { FoundryClient } from "@dtfoundry/foundry-sdk";
const foundry = new FoundryClient(platformBaseUrl, apiKey);
const created = await foundry.createTrustable({
content_type: "invoice",
schema: { id: "urn:acme:schema:invoice", version: "1.0.0" },
subject: { primary: { description: "Invoice 4471" } },
content: { amount: 1200, currency: "EUR" },
lifecycle: {
model: "transaction",
event: "issue",
state: "issued",
effective_time: new Date().toISOString(),
},
publication: { status_surface: "tel", visibility: "permissioned" },
signing_authority: signingAuthority,
signature_policy: { threshold: 1, required_roles: [] },
idempotency_key: `${invoiceId}:create`,
});
if (created.kind === "pending") {
const settled = await foundry.waitForRequest(created.requestId);
// settled.status is one of completed | failed | timed_out | canceled
}baseUrl is the platform_base_url delivered at provisioning. It must be
https — the credential is a long-lived bearer token, so one plaintext
deployment leaks a tenant-wide key permanently. http://localhost is accepted
for local development. apiKey is the api_key delivered at provisioning:
treat it as opaque, and never send it as X-API-Key, which the API refuses for
application credentials.
Rotation is a push from Auth, not a refresh the client performs. When a key is replaced, drop the client and build a new one — a credential mutated in place races the requests already in flight.
Options
| Option | Default | Meaning |
| -------------------------- | -------------- | ------------------------------- |
| timeoutMs | 30000 | Per attempt, not per call |
| maxRetries | 2 | Retries after the first attempt |
| userAgent | — | Appended to the SDK's own |
| requestIdFactory | random UUID v4 | Per-call X-Request-Id |
| onRequest / onResponse | — | Observability hooks |
| transport | fetch | Injection seam for tests |
The hooks are a side channel: one that throws does not change the outcome of a call. The credential never appears in either payload.
The surface
| Method | Endpoint |
| ----------------- | --------------------------------------------- |
| createTrustable | POST /api/v1/trustables |
| listTrustables | GET /api/v1/trustables |
| getTrustable | GET /api/v1/trustables/{said} |
| appendEvent | POST /api/v1/trustables/{said}/events |
| getStats | GET /api/v1/trustables/stats |
| listSigners | GET /api/v1/trustables/signers |
| createChallenge | POST /api/v1/trustables/challenges |
| getRequest | GET /api/v1/trustables/requests/{requestId} |
| listRequests | GET /api/v1/trustables/requests |
| verify | POST /api/v1/trustables/{said}/verify |
| getIssuerAid | GET /api/v1/tenant/issuer-aid |
| getBranding | GET /api/v1/tenant/branding |
waitForRequest(requestId, { timeoutMs, signal }) polls a request to a terminal
status. Every method takes an optional trailing { signal }.
Three things the types cannot tell you
An idempotency key is tenant-wide per operation, and does not include the
target. The lookup is keyed on the tenant, the operation and the key — the
SAID you are appending to is in neither the key nor the payload hash. The same
key with the same body, sent against two different trustables, replays the first
append and never touches the second. Derive the key from the target:
`${said}:append:${callerId}`. Keys are limited to 255 characters and the
client rejects a longer one before sending.
A failed write may still have landed. The server records the idempotency key
at the end of the operation, so a retry issued after it accepted the first
attempt finds no record and runs the whole thing again — which, on a
signature-collecting write, is a second approval prompt on someone's phone. The
SDK therefore retries a write only where the request provably never reached the
handler, and marks everything else mayHaveLanded:
try {
await foundry.appendEvent(said, body);
} catch (error) {
if (error instanceof FoundryError && error.mayHaveLanded) {
// Reconcile — read the trustable, or list requests. Do not re-send.
}
}Reads, and verify, retry freely.
A 202 does not mean pending. A multi-signer write always answers 202, and
its status is re-read after dispatch, so an all-custodial set that met its
threshold comes back 202-and-finished — and a dispatch that failed comes back
202-and-rejected. Meanwhile a message append answers 201 while echoing the
trustable's pending_signatures, which belongs to the trustable rather than to
that write. So createTrustable and appendEvent return the whole response
body with a kind added:
const result = await foundry.appendEvent(said, body);
switch (result.kind) {
case "completed": // finished synchronously
case "pending": // poll result.requestId
case "rejected": // a signer declined; read the request for the reason
}Because the server stamps a caller's lifecycle.state verbatim,
pending_signature, pending_signatures and signature_rejected are reserved:
a write naming one of them would have its own outcome misread. The client
refuses them.
Errors
Everything the SDK raises derives from FoundryError, so one catch covers the
whole surface: AuthenticationError, InsufficientScopeError, NotFoundError,
ValidationError (and its ClientValidationError subtype for checks that ran
before anything was sent), ConflictError, ServiceUnavailableError,
RateLimitError, TransportError, TimeoutError, CancelledError.
error.credentialRejected means the key is dead — after a rotation, or signed
under another instance's secret. Drop the client rather than retrying.
Regenerating the types
Request and response types are generated from the Platform partner OpenAPI document and committed, so consuming the package never runs the generator. After that document changes:
pnpm --filter @dtfoundry/foundry-sdk generate