SDK: API Reference
Complete API reference for the Grizzly SDK.
KeyClient
The main entry point for interacting with the Grizzly platform.
Static Methods
KeyClient.create(options: KeyClientOptions): Promise<IKeyClient>
Creates and initializes a new KeyClient instance.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
options.host |
string | Yes | Base URL of your Grizzly instance (e.g., https://tenant.grizzlycbg.io) |
options.asymHost |
string | No | URL for Asymmetric Key Service (defaults to {host}/asym) |
options.apikey |
string | Yes | API Key for authentication |
options.keycache.max |
number | No | Maximum number of keys to cache in memory |
options.logger |
Logger | No | Structured logger sink (see Logger & Observability Hooks). Injected into the SDK-owned HTTP transport instead of console |
options.hooks |
ObservabilityHooks | No | Request-lifecycle hooks (onRequest / onResponse / onRetry) fired across the asset/activity/admin transport |
options.tagSchema |
TagSchema | No | Default tag schema validated client-side against every asset data bag before it reaches the wire |
Returns: Promise<IKeyClient>
Example:
const client = await KeyClient.create({
host: 'https://your-tenant.grizzlycbg.io',
apikey: 'apikey-123456-1234567890',
keycache: { max: 100 }
})
Properties
ring: IKeyRingWarden (readonly)
Access to the KeyRingWarden for managing KeyRings.
store: IKeyStore (readonly)
The underlying key storage implementation.
utils: KeyClientUtils (readonly)
Utility functions for working with the SDK.
assets: IAssetClient (readonly)
The asset namespace — tagged encrypt plus read/search/update/family/delete/anchors/bulk over the SDK-owned HTTP transport. See Assets (client.assets). HTTP operations require a host; assets.encrypt (which rides the crypto path) works without one.
activities: IActivityClient (readonly)
The activity namespace — read-only search over the platform's audit/activity records. See Activities (client.activities). Requires a host.
admin: IAdminClient (readonly)
The admin/RBAC namespace — admin.accounts and admin.apiKeys, the primitives an IdP-driven (Okta/SCIM-style) integration drives over the auth-server routes. See Admin / RBAC (client.admin). Requires a host.
bulk: Bulk (readonly)
High-throughput batch encrypt / decrypt / rotate over a bounded-concurrency pool with per-item partial-failure isolation. See Bulk Operations (client.bulk).
Methods
encrypt(data: string | Buffer | Readable, keyring: string, options?: EncryptOptions): Promise<string | Buffer | Readable>
Encrypts data using a specified KeyRing.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
data |
string | Buffer | Readable | Yes | Data to encrypt |
keyring |
string | Yes | Name of the KeyRing to use |
options |
EncryptOptions | No | Encryption options |
options.asset |
string | object | No | Asset metadata (cryptographically signed and embedded in header) |
options.outFormat |
EncodingFormat | No | Output format: 'string', 'buffer', or 'readable-stream' |
options.inEncoding |
Encoding | No | Input data encoding |
options.outEncoding |
Encoding | No | Output data encoding |
Returns: Promise<string | Buffer | Readable> - Encrypted data in the same format as input (unless outFormat is specified)
Example:
// Encrypt string
const encrypted = await client.encrypt('Secret data', 'Finance')
// Encrypt with Asset (simple string)
const encrypted = await client.encrypt('User data', 'UserData', {
asset: 'user-123'
})
// Encrypt with Asset (complex metadata)
const encrypted = await client.encrypt('Sensitive document', 'Documents', {
asset: {
id: 'doc-789',
type: 'contract',
owner: 'user-456',
classification: 'confidential'
}
})
// Encrypt stream
const fileStream = fs.createReadStream('document.pdf')
const encryptedStream = await client.encrypt(fileStream, 'Documents')
decrypt(data: string | Buffer | Readable, privateKey?: string, options?: EncodingOptions): Promise<string | Buffer | Readable>
Decrypts data. The KeyRing used for encryption is automatically determined from the data's header.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
data |
string | Buffer | Readable | Yes | Encrypted data to decrypt |
privateKey |
string | No | Private key if using customer-managed keys |
options |
EncodingOptions | No | Encoding options |
options.asset |
string | object | No | Asset metadata for tracking (optional during decryption) |
options.outFormat |
EncodingFormat | No | Output format: 'string', 'buffer', or 'readable-stream' |
options.inEncoding |
Encoding | No | Input data encoding |
options.outEncoding |
Encoding | No | Output data encoding |
Returns: Promise<string | Buffer | Readable> - Decrypted data
Example:
// Decrypt string
const decrypted = await client.decrypt(encryptedData)
// Decrypt with private key (customer-managed keys)
const privateKey = fs.readFileSync('./private.pem', 'utf-8')
const decrypted = await client.decrypt(encryptedData, privateKey)
// Decrypt stream
const encryptedStream = fs.createReadStream('encrypted.bin')
const decryptedStream = await client.decrypt(encryptedStream)
KeyRingWarden
Manages KeyRing lifecycle operations. Access via client.ring.
Methods
create(name: string, options?: KeyRingCreateOptions): Promise<IKeyRing>
Creates a new KeyRing with a unique name.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Unique name for the KeyRing |
options |
KeyRingCreateOptions | No | Configuration options |
options.algo |
Algorithm | No | Encryption algorithm (default: 'aes256') |
options.publicKey |
string | No | PEM-formatted RSA public key |
options.privateKey |
string | No | PEM-formatted RSA private key |
options.config |
KeyRingConfig | No | KeyRing configuration |
options.config.props |
object | No | Additional KeyRing metadata |
options.config.key.props |
object | No | Metadata for keys created in this KeyRing |
options.config.algos.aes256.maxEncryptCount |
number | No | Number of encryptions before key rotation |
Returns: Promise<IKeyRing> - The created KeyRing
Example:
// Create with defaults
const ring = await client.ring.create('TeamA')
// Create with configuration
const ring = await client.ring.create('Finance', {
config: {
props: { team: 'finance', region: 'us-west' },
algos: {
aes256: {
maxEncryptCount: 50000
}
}
}
})
// Create with customer-managed keys
const publicKey = fs.readFileSync('./public.pem', 'utf-8')
const privateKey = fs.readFileSync('./private.pem', 'utf-8')
const ring = await client.ring.create('Secure', {
publicKey,
privateKey
})
get(name: string): Promise<IKeyRing | undefined>
Retrieves a KeyRing by name.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | KeyRing name |
Returns: Promise<IKeyRing | undefined> - The KeyRing, or undefined if not found
Example:
const ring = await client.ring.get('Finance')
if (ring) {
const encrypted = await ring.encrypt('data')
}
getById(id: string): Promise<IKeyRing | undefined>
Retrieves a KeyRing by ID.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | KeyRing ID |
Returns: Promise<IKeyRing | undefined> - The KeyRing, or undefined if not found
Example:
const ring = await client.ring.getById('ring-123456')
getAll(): Promise<string[]>
Lists all KeyRing names. This eagerly walks every page of the tenant's KeyRings and buffers all names into memory — fine for modest tenants, but for large or unbounded sets prefer iterate() (below), which streams one page at a time.
Returns: Promise<string[]> - Array of KeyRing names
Example:
const rings = await client.ring.getAll()
console.log(rings) // ['Finance', 'HR', 'Engineering']
iterate(options?: { pageSize?: number }): AsyncIterable<string>
Lazily iterates every KeyRing name, fetching one page at a time. Unlike getAll(), the full set is never buffered: each page is pulled only as the iterator advances, so a large tenant can be walked in bounded memory.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
options.pageSize |
number | No | Server page-size hint (maps to the route's limit). Default 100 |
Returns: AsyncIterable<string> - Yields each KeyRing name
Example:
for await (const name of client.ring.iterate({ pageSize: 500 })) {
console.log(name)
}
delete(name: string): Promise<void>
Deletes a KeyRing. This operation cannot be undone.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | KeyRing name to delete |
Returns: Promise<void>
Example:
await client.ring.delete('OldKeyRing')
KeyRing
Represents a single KeyRing. Obtained through KeyRingWarden methods.
Properties
id: string (readonly)
The unique identifier for this KeyRing.
name: string (readonly)
The KeyRing name (lowercase).
displayName: string (readonly)
The KeyRing name with original casing preserved.
activeKey: Key (readonly)
The currently active encryption key.
Methods
encrypt(data: string | Buffer | Readable, options?: EncodingOptions): Promise<string | Buffer | Readable>
Encrypts data using this KeyRing's active key.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
data |
string | Buffer | Readable | Yes | Data to encrypt |
options |
EncodingOptions | No | Encoding options |
options.asset |
string | object | No | Asset metadata (cryptographically signed and embedded in header) |
options.outFormat |
EncodingFormat | No | Output format |
options.inEncoding |
Encoding | No | Input encoding |
options.outEncoding |
Encoding | No | Output encoding |
Returns: Promise<string | Buffer | Readable> - Encrypted data
Example:
const ring = await client.ring.get('Finance')
// Encrypt string
const encrypted = await ring.encrypt('Sensitive data')
// Encrypt with Asset metadata
const encrypted = await ring.encrypt('User record', {
asset: {
id: 'user-456',
type: 'user-record',
department: 'finance'
}
})
// Encrypt buffer
const buffer = Buffer.from('Binary data')
const encrypted = await ring.encrypt(buffer)
decrypt(data: string | Buffer | Readable, options?: EncodingOptions): Promise<string | Buffer | Readable>
Decrypts data that was encrypted with this KeyRing.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
data |
string | Buffer | Readable | Yes | Encrypted data |
options |
EncodingOptions | No | Encoding options |
Returns: Promise<string | Buffer | Readable> - Decrypted data
Example:
const ring = await client.ring.get('Finance')
const decrypted = await ring.decrypt(encryptedData)
rotate(algo?: Algorithm): Promise<Key>
Manually rotates the KeyRing's active key. Normally, keys rotate automatically based on configuration.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
algo |
Algorithm | No | Algorithm for new key (default: current algorithm) |
Returns: Promise<Key> - The new active key
Example:
const ring = await client.ring.get('Finance')
// Rotate key
await ring.rotate()
// New encryptions use the new key
const encrypted = await ring.encrypt('New data')
// Old data still decrypts with rotated keys
const old = await ring.decrypt(oldEncryptedData)
getConfig(): Promise<KeyRingConfig>
Retrieves the current KeyRing configuration.
Returns: Promise<KeyRingConfig> - Current configuration
Example:
const ring = await client.ring.get('Finance')
const config = await ring.getConfig()
console.log(config.algos.aes256.maxEncryptCount)
updateConfig(handler: KeyRingConfigUpdateHandler): Promise<void>
Updates the KeyRing configuration.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
handler |
function | Yes | Callback to modify config |
Handler signature: (config: KeyRingConfig) => void
Returns: Promise<void>
Example:
const ring = await client.ring.get('Finance')
await ring.updateConfig((config) => {
config.algos.aes256.maxEncryptCount = 100000
config.props.team = 'finance-v2'
})
Assets (client.assets)
The asset namespace attaches business metadata (the data bag) to encrypted data and reads it back over the SDK-owned HTTP transport. Access via client.assets.
Methods
encrypt(data: string | Buffer | Readable, keyring: string, options?: EncryptAssetOptions): Promise<EncryptAssetResult>
Encrypts data on keyring and creates the associated Asset in one call, returning both the ciphertext and a reference to the created Asset. This is the only encrypt that returns the Asset id. It reuses the exact same crypto path as ring.encrypt — there is no parallel crypto.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
data |
string | Buffer | Readable | Yes | Data to encrypt |
keyring |
string | Yes | Name of the KeyRing to use |
options.data |
object | No | Business metadata for the Asset. Populate data.name with a human-readable label — it becomes the display name in activity logs and the Dashboard |
options.parents |
string[] | No | Parent Asset ids to relate this new Asset to |
options.assetId |
string | No | Pre-allocated asset-<uuid> (see newAssetId). Supplying the same id for a retried request makes the operation idempotent — the first call creates the Asset with that id, a retry re-tags the same record. Mint it with newAssetId() |
options.hydrate |
boolean | No | When true, follows the encrypt with a GET /assets/:id so the returned asset carries createdAt. Costs one extra round trip. Default false |
options.tagSchema |
TagSchema | No | Per-call tag schema; overrides the client-level default. A violation throws a ValidationError before anything is encrypted |
options.outFormat / options.inEncoding / options.outEncoding |
— | No | Same encoding options as ring.encrypt |
Returns: Promise<EncryptAssetResult> — { data, asset }, where data is the ciphertext and asset is an AssetRef (always carries id; fully populated when hydrate: true).
Example:
import { newAssetId } from '@grizzlycbg/grizzly-sdk'
const { data: ciphertext, asset } = await client.assets.encrypt('Secret record', 'Finance', {
data: { name: 'Q4 Ledger', type: 'financial' },
parents: ['asset-9f2c...'],
assetId: newAssetId() // pre-mint for idempotent retries
})
console.log(asset.id) // 'asset-4a1e...'
get(id: string): Promise<Asset | undefined>
Retrieves a single Asset by id, including its data, parent relationships, and full activity history. Returns undefined if no Asset matches.
search(query: AssetQuery): Promise<Page<AssetSummary>>
Returns one page of matching Assets (no cursor). Each hit is an AssetSummary (id, createdAt, data — without the potentially large parents/activities).
query.parents and query.everParentedBy look similar but read different things: parents matches an asset's current parent set, while everParentedBy matches any asset that was ever parented by the given id, including relationships that have since been removed. Use parents for "what does this asset own right now" and everParentedBy for "what has ever passed through this asset" — the latter is what a lineage or compliance query usually wants.
searchAll(query: AssetQuery): AsyncIterable<AssetSummary>
Lazily iterates every matching Asset, fetching each page only as the iterator crosses into it — the full result set is never buffered.
Example:
for await (const asset of client.assets.searchAll({ data: { type: 'phi' } })) {
console.log(asset.id, asset.data.name)
}
family(id: string, options?: FamilyOptions): Promise<AssetFamily>
Returns the Asset together with its ancestors, descendants, and a hierarchy summary.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | The Asset id |
options.maxDepth |
number | No | Limit how many hierarchy levels are walked |
options.accountId / options.keyringId |
string | No | Scope the traversal |
Returns: Promise<AssetFamily> — { asset, ancestors, descendants, summary }.
update(id: string, patch: AssetUpdate): Promise<Asset>
Applies a metadata-only patch (data and/or parents) to an existing Asset and returns the updated Asset. The ciphertext is never touched — decrypting the Asset's data still works after an update. Parent deltas are resolved server-side per patch.parentMode.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | The Asset id |
patch.data |
object | No | New business metadata (replaces the stored data when present) |
patch.parents |
string[] | No | Parent ids to apply, interpreted by parentMode |
patch.parentMode |
RelationshipMode | No | How to apply parents: Replace (default) / Add / Remove |
patch.context |
object | No | Extra context recorded on the update activity |
patch.tagSchema |
TagSchema | No | Per-call tag schema, validated against patch.data only when data is present |
Returns: Promise<Asset> — the updated Asset.
Parent relationships form a directed acyclic graph — an asset can never become its own ancestor, at any depth. A
parents/parentModechange that would create a cycle is rejected with status 409 and nothing is applied. This only applies to changes on an existing asset (update); a newly created asset cannot close a cycle no matter what parents it is given. See Asset Tagging: Relationships Form a DAG for how to model genuine mutual derivation instead of cycling it.
Example:
import { RelationshipMode } from '@grizzlycbg/grizzly-sdk'
// Re-tag (metadata only — ciphertext untouched)
await client.assets.update('asset-123', { data: { name: 'Q4 Ledger (Approved)', state: 'approved' } })
// Add a parent edge without disturbing existing ones
await client.assets.update('asset-123', {
parents: ['asset-parent-9'],
parentMode: RelationshipMode.Add
})
// A relationship that would create a cycle is rejected — nothing is applied
try {
await client.assets.update('asset-a', { parents: ['asset-c'], parentMode: RelationshipMode.Add })
} catch (err) {
if (err.status === 409) {
// asset-c already descends from asset-a — model the relationship instead of cycling it
}
}
delete(id: string): Promise<void>
Deletes an Asset by id. Deletion is idempotent — deleting an already-deleted (or never-existing) id succeeds without error.
The asset's activity trail is not deleted with it: it remains reachable via activities below, with every entry's data erased down to {} (the fact that an action occurred, and who performed it, survives; the content it carried does not). The deletion itself is recorded as its own activity, and any surviving child that named the deleted asset as a parent gets a parent.removed activity explaining why the link disappeared. Decrypting ciphertext that belonged to a deleted asset continues to work — its decrypt activity is recorded the same redacted way.
activities(id: string, page?: PageRequest): Promise<Page<AssetActivity>>
Returns one page of id's activity trail. Unlike get, this keeps working after the asset has been deleted — it's the way to retrieve a deleted asset's audit history for a compliance review or investigation.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | The Asset id |
page.cursor |
string | No | Pagination cursor for the next page |
page.limit |
number | No | Items per page |
Returns: Promise<Page<AssetActivity>>
Example:
await client.assets.delete('asset-123')
// Still works — the trail survives the asset
const page = await client.assets.activities('asset-123')
for (const activity of page.data) {
console.log(activity.action, activity.createdAt, activity.data) // data is {} for a deleted asset
}
anchors(id: string, page?: PageRequest): Promise<Page<SigningAnchor>>
Returns one page of the provenance/signing anchors recorded for an Asset.
bulk<T, R>(items: T[], options: BulkExecuteOptions<T, R>): Promise<BulkResult<R>>
Runs a caller-supplied operation over many items on a bounded-concurrency pool, returning a per-item partial-failure report. This is the generic executor behind backfill / event-stream workloads — pair it with client-minted assetIds for idempotent retries. Shares the same pool and result vocabulary as client.bulk.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
items |
T[] | Yes | The work items |
options.operation |
(item, index) => Promise<R> |
Yes | Unit of work per item; its resolved value becomes the item's result |
options.id |
(item, index) => string |
No | Derives the result id echoed back per item (for retry-by-id). Defaults to the item index |
options.concurrency |
number | No | Max simultaneous in-flight operations. Default 25 |
options.stopOnError |
boolean | No | Stop scheduling new work after the first failure. Default false (partial-failure) |
options.signal |
AbortSignal | No | Abort the batch; unscheduled items come back marked unprocessed |
options.onProgress |
(progress: BulkProgress) => void |
No | Fired after each item settles |
Returns: Promise<BulkResult<R>> — ordered per-item results plus total / succeeded / failed counts.
Bulk Operations (client.bulk)
High-throughput batch encrypt / decrypt / rotate over many items on a single bounded-concurrency pool. Per-item errors are isolated: one failure never rejects the batch — failed and unprocessed items come back with ok: false so callers retry by id. Access via client.bulk.
encrypt(items: BulkItem[], options?: BulkEncryptOptions): Promise<BulkResult>
Encrypts each item. Each distinct keyring is resolved exactly once. An item may carry its own keyring; otherwise options.keyring is used.
decrypt(items: BulkItem[], options?: BulkDecryptOptions): Promise<BulkResult>
Decrypts each item. An item may carry its own privateKey (BulkItem.privateKey); otherwise options.privateKey is used, so a batch can span keyrings with different private keys.
rotate(keyrings: string[], options?: BulkRotateOptions): Promise<BulkResult<void>>
Rotates each named keyring. One options set (algo) applies to every ring in the batch — to rotate rings with distinct algorithms, issue separate calls. Defaults to a concurrency of 8.
Shared control options (all of encrypt/decrypt/rotate): concurrency (default 25, or 8 for rotate), stopOnError (default false), signal, onProgress.
BulkItem shape: { id, data, keyring?, privateKey? } — id is echoed back on the result so failures can be retried by id.
Example:
const result = await client.bulk.encrypt(
[
{ id: 'rec-1', data: 'first' },
{ id: 'rec-2', data: 'second' }
],
{ keyring: 'Finance', concurrency: 50 }
)
console.log(result.succeeded, result.failed)
const retryable = result.results.filter((r) => !r.ok).map((r) => r.id)
Activities (client.activities)
Read-only search over the platform's audit/activity records — the primitive a compliance / SIEM export integration drives. Mirrors the assets namespace's pagination + laziness contract. Access via client.activities.
search(query: ActivityQuery): Promise<Page<Activity>>
One page of matching activities (no cursor).
searchAll(query: ActivityQuery): AsyncIterable<Activity>
Lazily iterates every matching activity, fetching each page only as it is reached.
ActivityQuery fields (all optional, AND-combined; an empty query matches everything): accountId, accountUid, keyid (an API Key id), action, ring (a KeyRing name, not an id), valid (was the action granted?), time ({ start?, end? } ISO-8601), props (exact-match subset of stored props).
Activity.createdAtis epoch milliseconds (anumber), unlike the ISO-8601 strings elsewhere in the SDK.
Example:
for await (const activity of client.activities.searchAll({ action: 'encrypt', valid: true })) {
console.log(activity.keyid, activity.createdAt)
}
Admin / RBAC (client.admin)
The identity primitives an IdP-driven (Okta / SCIM-style) integration drives over the existing auth-server routes. Two sub-namespaces: admin.accounts manages identities, admin.apiKeys manages the access credentials (entitlements) that bridge an Account to the KeyRings it may use. To grant access you create an API Key with entitlements; to revoke you delete or disable it.
client.admin.accounts
| Method | Returns | Description |
|---|---|---|
create(input: CreateAccountInput) |
Promise<Account> |
Creates an Account (input.uid required) |
get(id, options?) |
Promise<Account \| undefined> |
Reads one Account by id (or uid via options.isUid) |
list(page?, options?) |
Promise<Page<Account>> |
One page of Accounts |
listAll(options?) |
AsyncIterable<Account> |
Lazily iterates every Account |
deactivate(id, ref?) |
Promise<Account> |
Soft-deletes an Account. Deactivation also disables every API Key on the account, immediately revoking its access. Returns the deactivated Account |
client.admin.apiKeys
| Method | Returns | Description |
|---|---|---|
create(input: CreateApiKeyInput) |
Promise<ApiKey> |
Creates an API Key for an Account. The returned key carries the secret key (returned once, on create) |
get(apikeyId) |
Promise<SafeApiKey \| undefined> |
Reads one API Key (never exposes the secret) |
list(page?) |
Promise<Page<SafeApiKey>> |
One page of API Keys across all accounts |
listAll() |
AsyncIterable<SafeApiKey> |
Lazily iterates every API Key |
listByAccount(accountId, page?, ref?) |
Promise<Page<SafeApiKey>> |
One page of an Account's API Keys |
listAllByAccount(accountId, ref?) |
AsyncIterable<SafeApiKey> |
Lazily iterates an Account's API Keys |
delete(apikeyId) |
Promise<void> |
Permanently revokes an API Key |
disable(apikeyId) |
Promise<SafeApiKey> |
Revokes access without deleting the record |
updateEntitlements(apikeyId, flags, mode?) |
Promise<SafeApiKey> |
Updates entitlements; mode is EntitlementMode.Merge (default) / Replace / Delete |
Entitlements are a list of names.
flagsis astring[]in requests and in responses alike; an entitlement is granted by being present in it. The{ 'keyring.finance.decrypt': true }object earlier versions accepted is rejected with a400, and there is no way to express a denial — leave the name out.Casing is no longer your problem. Every name is trimmed, lower-cased and de-duplicated by the server, on all four paths: create, Merge, Replace and Delete. This note used to warn that
Deletematched verbatim, so revokingKeyring.Finance.readsilently removed nothing; that asymmetry is gone. Pass whatever spelling you like.
Example:
import { EntitlementMode } from '@grizzlycbg/grizzly-sdk'
const account = await client.admin.accounts.create({ uid: 'Account-Export-Service' })
const apiKey = await client.admin.apiKeys.create({
accountId: account.id,
flags: ['keyring.finance.decrypt']
})
console.log(apiKey.key) // secret — shown only here
// Later: revoke by disabling the key
await client.admin.apiKeys.disable(apiKey.keyid)
Client-Minted Asset IDs
Mint an asset-<uuid> up front so a retried encrypt addresses the same record (idempotency). Mirrors the server's format and validation.
newAssetId(): string — Mints a fresh, well-formed asset id (asset-<uuid>).
isValidAssetId(id: string): boolean — True when id matches the platform's asset-<uuid> format.
Pass the minted id as EncryptAssetOptions.assetId. The first call creates the Asset with that id; a retry with the same id re-tags the same record (no duplicate). A concurrent create that loses the race is returned as a clean 409, and a malformed id is rejected with 400.
Tag Schema Validation
TagSchema validates an Asset's data bag before it hits the wire — a hand-rolled check (no JSON-schema dependency) of required keys, per-key runtime types, and an optional whole-bag custom function. A violation throws a ValidationError (code tag_schema.violation) client-side; the bad request never leaves the process.
Attach a schema per client (KeyClientOptions.tagSchema) or per call (encrypt/update options); a per-call schema overrides the client default. It is wired into assets.encrypt, assets.update, and — transitively — assets.bulk.
new TagSchema(init: TagSchemaInit)
type TagType = 'string' | 'number' | 'boolean' | 'object'
type TagRule = { type?: TagType; required?: boolean }
type TagSchemaInit = {
fields?: Record<string, TagRule> // per-key rules
required?: string[] // required keys (merged with per-field required:true)
validate?: (data: object) => true | string // whole-bag check; return true or a violation message
}
Example:
import { KeyClient, TagSchema } from '@grizzlycbg/grizzly-sdk'
const schema = new TagSchema({
fields: { name: { type: 'string', required: true }, retentionYears: { type: 'number' } }
})
const client = await KeyClient.create({ host, apikey, tagSchema: schema })
// Throws ValidationError('Tag schema violation: required key "name" is missing.') — nothing is encrypted
await client.assets.encrypt('data', 'Finance', { data: { retentionYears: 7 } })
Logger & Observability Hooks
Logger — a structured logger sink injected via KeyClientOptions.logger; the SDK-owned transport uses it instead of console. All methods are optional:
interface Logger {
debug?(message: string, meta?: Record<string, unknown>): void
info?(message: string, meta?: Record<string, unknown>): void
warn?(message: string, meta?: Record<string, unknown>): void
error?(message: string, meta?: Record<string, unknown>): void
}
Supplying a logger also opts the legacy transport into its error channel, surfacing real decrypt failures as typed GrizzlyErrors (code decrypt.failed) instead of a misleading "No KeyRing/Key pair found".
ObservabilityHooks — request-lifecycle hooks injected via KeyClientOptions.hooks, additive alongside the logger:
interface ObservabilityHooks {
onRequest?(info: RequestHookInfo): void // before each attempt's fetch
onResponse?(info: ResponseHookInfo): void // when a response arrives (every attempt)
onRetry?(info: RetryHookInfo): void // when a retry is scheduled
}
RequestHookInfo is { method, path, requestId, attempt } (attempt is 1-based); onResponse adds { status, durationMs }; onRetry adds { reason, delayMs }.
Hooks are observability only. An exception thrown from a hook is caught and logged, never propagated — a throwing hook can never skip a fetch, drop a response, or cancel a retry.
Offline Verification (verify)
Signed assets can be verified offline — with no live connection to the platform — via the re-exported verify namespace, so one dependency covers both integration and verification. Verification-only: it cannot sign.
import { verify } from '@grizzlycbg/grizzly-sdk'
const keyset = await verify.getSigningKeyset(host, ringId, { apiKey }) // mirror a ring's keyset once
const result = await verify.verify(signedAsset, keyset) // then verify offline
The SDK also re-exports the typed signing errors and their guards so an encrypt failure can be branched precisely: SigningPausedError / isSigningPausedError (a deterministic pause — do not retry), AsymUnavailableError / isAsymUnavailableError (a retryable asym-key-server outage), and SigningRequiredError / isSigningRequiredError (a signing-required policy), keyed off the SigningErrorCode discriminant. These are separate from the GrizzlyError hierarchy.
Type Definitions
Algorithm
Supported encryption algorithms:
type Algorithm = 'aes256'
EncodingFormat
Output format options:
type EncodingFormat = 'string' | 'buffer' | 'readable-stream'
Encoding
Character encoding options:
type Encoding = 'utf8' | 'ascii' | 'base64' | 'hex' | 'binary' | 'utf16le'
KeyRingConfig
interface KeyRingConfig {
props?: Record<string, any>
key?: {
props?: Record<string, any>
}
algos: {
aes256?: {
maxEncryptCount: number
}
}
}
Asset types
Timestamps cross the wire as ISO-8601 strings (except Activity.createdAt, which is epoch ms).
type Asset = {
id: string
createdAt: string
data: object // the business-metadata bag
parents: { assetId: string; createdAt: string }[]
activities: AssetActivity[]
}
type AssetSummary = Pick<Asset, 'id' | 'createdAt' | 'data'> // a search hit
type AssetRef = {
id: string
keyringId: string
parents: string[]
data: object
createdAt?: string // present when hydrated
}
type EncryptAssetResult = {
data: string | Buffer | Readable // ciphertext
asset: AssetRef
}
enum RelationshipMode { Replace = 'replace', Add = 'add', Remove = 'remove' }
type AssetUpdate = {
data?: object
parents?: string[]
parentMode?: RelationshipMode // default Replace
context?: object
tagSchema?: TagSchema
}
type AssetQuery = {
accountId?: string
keyringId?: string
apikeyId?: string
parents?: string[] // CURRENT state: assets whose parent set right now contains any of these ids
everParentedBy?: string // HISTORY: assets ever parented by this id, including removed links
time?: { start?: string; end?: string } // ISO-8601
data?: object // exact-match
}
type Page<T> = {
data: T[]
pagination: { limit: number; count: number; hasMore: boolean; nextCursor?: string }
}
Bulk types
type BulkItem = { id: string; data: string | Buffer | Readable; keyring?: string; privateKey?: string }
type BulkItemResult<T> =
| { id: string; ok: true; result: T }
| { id: string; ok: false; error: { message: string; code?: string } }
type BulkResult<T> = {
results: BulkItemResult<T>[]
total: number
succeeded: number
failed: number
}
Error Handling
SDK Error Types (GrizzlyError)
The asset, activity, and admin surfaces (client.assets, client.activities, client.admin, client.bulk) raise a typed GrizzlyError hierarchy over the SDK-owned transport — never a bare Error(message) or a swallowed undefined. Every error carries:
| Field | Type | Description |
|---|---|---|
status |
number? | HTTP status, when the error came from a response |
code |
string | Stable machine code — validation / unauthorized / forbidden / not_found / rate_limited / server_error / network, or SDK-local decrypt.failed / tag_schema.violation / aborted. Safe to branch on |
retryable |
boolean | True when retrying the same request is safe |
requestId |
string? | The x-request-id sent with the call, for correlating to a server Activity |
Branch on the class or the code:
| Class | Status | Notes |
|---|---|---|
AuthError |
401 / 403 | API key missing, invalid, or lacking the entitlement |
NotFoundError |
404 | The addressed resource does not exist |
ValidationError |
400 | Rejected as invalid (bad body/cursor; also thrown client-side by tag schemas) |
RateLimitError |
429 | Rate limited; retryAfterMs? carries the server's Retry-After when present |
ServerError |
5xx / network | Server-side or connect failure. Retryable |
GrizzlyError |
— | Base class / any other status |
import { NotFoundError, RateLimitError, ValidationError } from '@grizzlycbg/grizzly-sdk'
try {
// assets.family is the one asset surface that 404s on a missing asset
await client.assets.family('asset-123')
await client.assets.update('asset-123', { data: { state: 'approved' } })
} catch (err) {
if (err instanceof NotFoundError) {
// 404 from assets.family — the asset does not exist
} else if (err instanceof RateLimitError) {
await new Promise((r) => setTimeout(r, err.retryAfterMs ?? 1000))
} else if (err instanceof ValidationError) {
// 400 from assets.update — the ADDRESSED asset is missing (a missing referenced
// *parent* id is a 404/NotFoundError instead); check err.code / err.message for the cause
} else {
throw err
}
}
assets.getis the exception: a missing asset returnsundefinedrather than throwingNotFoundError. Among the surfaces that do throw,assets.familyandassets.updateboth raiseNotFoundError(404) for a missing asset —assets.updateraises the same 404 whether the missing id is the target or a referenced parent, distinguished by the error message — andassets.deleteis idempotent, returning without throwing when the asset is already gone.
Retry semantics. The transport retries with exponential backoff and full jitter (default 2 retries — up to 3 attempts). 429 and 503 are always retried, honoring Retry-After. Other 5xx and connect errors are retried only for idempotent requests: GET/DELETE are idempotent by default and POST/PATCH are not, so a write that already received a definitive response is never silently replayed. See API Error Responses for the full taxonomy.
Legacy errors
The KeyRing crypto path (ring.encrypt / ring.decrypt, client.encrypt / client.decrypt) still throws standard JavaScript Error objects. Wrap those calls in try-catch:
try {
const ring = await client.ring.get('Finance')
const encrypted = await ring.encrypt('data')
} catch (error) {
console.error('Encryption failed:', error.message)
}
Common error scenarios:
- Authentication failures: Invalid API key or insufficient permissions
- KeyRing not found: Attempting to access non-existent KeyRing
- Network errors: Connection issues with Grizzly instance
- Decryption failures: Invalid encrypted data or missing keys
See Error Handling & Troubleshooting for detailed error resolution.