Skip to content

Node SDK

The first-party Lockwell SDK for Node.js is a native, encrypted alternative to the AWS S3 SDK for Node and Next.js apps. It is ESM and CommonJS, Promise-based, with zero runtime dependencies (built on the global fetch, crypto, and ReadableStream).

It shares the language-neutral SigV4 signing fixtures with the Go and Java SDKs, so all three sign byte-for-byte identically.

sh
npm install @kelphect/sdk

Requires Node.js >= 20. Both import { Client } from '@kelphect/sdk' (ESM) and const { Client } = require('@kelphect/sdk') (CJS) work.

The package is published privately during development as @kelphect/sdk (the npm scope must match the GitHub repo owner). It will be republished as @lockwell/sdk at public release.

Exports

ExportWhat it is
Clientthe S3 (SigV4 + XML) data-plane client. Node-only.
NativeClientthe native JSON data-plane client. Edge-safe.
AdminClientthe JSON admin client (admin listener).
LockwellKitthe high-level app kit.
verifyWebhookstandalone, edge-safe webhook verification.

Error helpers ship alongside the clients:

  • S3: APIError and isNotFound.
  • Native: NativeError, isNativeNotFound, isNativeConflict, isNativePreconditionFailed, isNativeForbidden, isNativeUnauthorized, isNativeQuotaExceeded.
  • Admin: AdminError and isAdminNotFound.

Plus RetryPolicy, sha256ChecksumBase64, computeChecksumBase64, checksumHeaderName, CHECKSUM_ALGORITHMS, buildPresignedGetUrl, and WEBHOOK_SIGNATURE_HEADER_NAME.

On edge runtimes, import from @kelphect/sdk/edge instead.

Client (the S3 client)

ts
import { 
Client
} from "@kelphect/sdk";
import {
createReadStream
} from "node:fs";
const
client
= new
Client
({
endpoint
: "https://objects.example.com",
accessKeyId
:
process
.
env
.
LOCKWELL_ACCESS_KEY_ID
!,
secretKey
:
process
.
env
.
LOCKWELL_SECRET_KEY
!,
}); // SSE-S3 at rest, a server-verified CRC64NVME checksum, and an idempotency key // for safe retries, in one call. const
put
= await
client
.
putObject
("reports", "q1.txt",
Buffer
.
from
("hello"), {
contentType
: "text/plain",
serverSideEncryption
: true,
checksumAlgorithm
: "CRC64NVME",
idempotencyKey
: "q1-2026",
}); const
got
= await
client
.
getObject
("reports", "q1.txt");
console
.
log
(
got
.
body
.
toString
());
// Stream a large file without buffering (checksum sent in an aws-chunked trailer). await
client
.
putObjectStream
("reports", "big.bin",
createReadStream
("big.bin"), "CRC64NVME");
// Presigned GET. Lockwell never issues presigned writes on the S3 client. const
url
=
client
.
presignGetObject
("reports", "q1.txt", 900);

Hover any identifier above: the tooltips are the SDK's real type signatures, checked at build time against the published type definitions.

Construction

Constructor options: { endpoint, accessKeyId, secretKey, fetch?, userAgent?, now?, retry? }.

Retry is opt-in: pass retry: RetryPolicy.default() or retry: { maxAttempts: 3 }; the default is a single attempt. fetch lets you inject a custom fetch implementation, and now overrides the signing clock (useful in tests).

Buckets

MethodSignature
createBucketcreateBucket(bucket, opts?) (opts { objectLock: { mode, days } })
headBucketheadBucket(bucket)
deleteBucketdeleteBucket(bucket)
putBucketVersioningputBucketVersioning(bucket, status) ('Enabled' / 'Suspended')
getBucketVersioninggetBucketVersioning(bucket) (returns '' when never enabled)

Objects

MethodSignature
putObjectputObject(bucket, key, body, opts?)
putObjectStreamputObjectStream(bucket, key, source, checksumAlgorithm, opts?)
getObjectgetObject(bucket, key, opts?) (fully buffered body)
getObjectStreamgetObjectStream(bucket, key, opts?) (body is the ReadableStream; readAll() buffers it)
headObjectheadObject(bucket, key, opts?)
deleteObjectdeleteObject(bucket, key, opts?) (opts.versionId)
deleteObjectsdeleteObjects(bucket, objects, opts?) (batch <= 1000)
copyObjectcopyObject(srcBucket, srcKey, srcVersionId, dstBucket, dstKey, opts?)

putObject options:

OptionEffect
contentTypeSets Content-Type.
metadataObject of user metadata, stored as x-amz-meta-*.
idempotencyKeyThe signed x-lockwell-idempotency-key; makes the write replay-safe.
serverSideEncryption: trueRequests SSE-S3 at rest.
checksumAlgorithmOne of CHECKSUM_ALGORITHMS; the SDK computes and sends the verified digest.
objectLockMode'GOVERNANCE' or 'COMPLIANCE', set on PUT.
objectLockRetainUntilRFC3339 retain-until date.
objectLockLegalHoldtrue/false (mapped to ON/OFF).

getObject / getObjectStream / headObject options: range, partNumber, versionId, responseContentType. getObjectStream is the right call for large objects: iterate body yourself or call readAll() to buffer into a Buffer.

deleteObjects takes entries of { key, versionId? } (or bare key strings) and returns { deleted, errors }. The batch may partially succeed; opts.quiet suppresses the per-key deleted entries (errors are always returned).

Listing and pagination

MethodSignature
listObjectsV2listObjectsV2(bucket, opts?) (prefix, delimiter, startAfter, continuationToken, maxKeys)
listObjectslistObjects(bucket, opts?) (v1: marker pagination)
listObjectVersionslistObjectVersions(bucket, opts?) (keyMarker, versionIdMarker, ...)
listMultipartUploadslistMultipartUploads(bucket, opts?)
listPartslistParts(bucket, key, uploadId, opts?)

Each list* has a paginate* async iterator that threads continuation/marker tokens for you:

js
for await (const page of client.paginateObjectsV2("reports", { prefix: "logs/" })) {
  for (const obj of page.objects) console.log(obj.key, obj.size);
}

The five iterators are paginateObjectsV2, paginateObjects, paginateObjectVersions, paginateMultipartUploads, and paginateParts.

Multipart

js
const mpu = await client.createMultipartUpload("reports", "big.bin", { checksumAlgorithm: "CRC32C" });
const p1 = await client.uploadPart("reports", "big.bin", mpu.uploadId, 1, part1, {
  checksumAlgorithm: mpu.checksumAlgorithm,
});
const p2 = await client.uploadPart("reports", "big.bin", mpu.uploadId, 2, part2, {
  checksumAlgorithm: mpu.checksumAlgorithm,
});
const done = await client.completeMultipartUpload("reports", "big.bin", mpu.uploadId, [
  { partNumber: 1, etag: p1.etag },
  { partNumber: 2, etag: p2.etag },
]);

Also uploadPartCopy(srcBucket, srcKey, srcVersionId, dstBucket, dstKey, uploadId, partNumber, byteRange) and abortMultipartUpload(bucket, key, uploadId). When you declare a checksumAlgorithm on create, pass it back on every uploadPart, and the composite checksum returns on complete.

Tagging and Object Lock reads

putObjectTagging(bucket, key, tags), getObjectTagging(bucket, key), deleteObjectTagging(bucket, key), getObjectRetention(bucket, key, opts?), and getObjectLegalHold(bucket, key, opts?). Retention and legal hold are set on the write through the putObject object-lock options above, then read back here.

Presigned GET

presignGetObject(bucket, key, expiresSeconds, opts?) returns a time-limited GET URL (opts.versionId, opts.responseContentType are folded into the signature). Presign is GET-only on the S3 client; for a signed write URL use the native signUrl.

Streaming uploads and checksums

putObjectStream(bucket, key, source, checksumAlgorithm, opts?) streams source (a ReadableStream or async-iterable) and sends the checksum in an aws-chunked trailer, so checksumAlgorithm is required.

The exported computeChecksumBase64(alg, data) and checksumHeaderName(alg) let you precompute a digest yourself. CHECKSUM_ALGORITHMS is the supported list (CRC32, CRC32C, CRC64NVME, SHA1, SHA256).

Errors

Client throws APIError with code, message, statusCode, requestId. Use isNotFound(err) for a missing bucket/key/upload.

js
import { isNotFound } from "@kelphect/sdk";
try {
  await client.getObject("reports", "missing.txt");
} catch (err) {
  if (isNotFound(err)) {
    /* 404 */
  } else throw err;
}

NativeClient (the native client)

The native JSON data plane at /api/v1/. No SigV4, no XML.

Configured with an access-key id plus secret, it auto-manages the bearer token: mints a lwtk_… token on first use, caches it until shortly before expiry, refreshes transparently, and re-mints once on a 401. Token acquisition is concurrency-safe, with a single in-flight mint shared across a burst of requests (single-flight), never one mint per request.

js
import { NativeClient, sha256ChecksumBase64 } from "@kelphect/sdk";

const native = new NativeClient({
  endpoint: "https://objects.example.com", // public listener (same as S3)
  accessKeyId: process.env.LOCKWELL_ACCESS_KEY_ID,
  secretKey: process.env.LOCKWELL_SECRET_KEY,
});

await native.createBucket("reports", { versioning: true });

// Body may be a Buffer/Uint8Array/string OR a ReadableStream/async-iterable.
// An idempotent PUT needs a body-integrity signal: pass an expected checksum.
const put = await native.putObject("reports", "q1.txt", Buffer.from("hello"), {
  contentType: "text/plain",
  idempotencyKey: "q1-2026",
  checksums: { sha256: await sha256ChecksumBase64("hello") },
});
console.log(put.versionId, put.etag);

// Streaming GET (no whole-object buffering) with a Range.
const got = await native.getObjectStream("reports", "big.bin", { range: "bytes=0-1023" });
for await (const chunk of got.body) {
  /* Uint8Array */
}

Buckets and objects

MethodSignature
listBucketslistBuckets()
createBucketcreateBucket(bucket, opts?) ({ versioning?, objectLock? })
getBucketgetBucket(bucket)
deleteBucketdeleteBucket(bucket)
getBucketVersioning / setBucketVersioningversioning state ('Enabled'/'Suspended')
putObjectputObject(bucket, key, body, opts?) (streaming)
getObject / getObjectStreambuffered / streaming download ({ range?, versionId? })
headObjectheadObject(bucket, key, opts?)
deleteObjectdeleteObject(bucket, key, opts?)
listObjectslistObjects(bucket, opts?)
batchDeleteObjectsbatchDeleteObjects(bucket, objects, opts?) (<= 1000)
copyObjectcopyObject(dstBucket, dstKey, source)

putObject options: { contentType?, metadata?, idempotencyKey?, ifMatch?, ifNoneMatch?, checksums?: { <alg>: <base64> }, contentLength? }. ifNoneMatch: '*' creates only when absent; ifMatch: '<etag>' overwrites only on a matching ETag. A bad checksums digest is rejected before any bytes are committed.

copyObject(dstBucket, dstKey, source) takes the source plus directives in source:

ts
{ sourceBucket, sourceKey, sourceVersionId?, metadataDirective?, contentType?, metadata?,
  ifMatch?, ifNoneMatch?, ifModifiedSince?, ifUnmodifiedSince?, requireAbsent?, requireMatchEtag? }

getObjectTags / setObjectTags / deleteObjectTags, getObjectRetention / setObjectRetention, getObjectLegalHold / setObjectLegalHold, listObjectVersions, and multipart (createMultipartUpload, uploadPart, listParts, completeMultipartUpload, abortMultipartUpload, listMultipartUploads).

The async iterators paginateObjects, paginateObjectVersions, and paginateMultipartUploads page automatically.

Signed URLs (GET and PUT)

The native API supports signed write URLs (unlike the S3 presigner):

js
const upload = await native.signUrl({ method: "PUT", bucket: "reports", key: "incoming.bin", ttlSeconds: 300 });
const download = await native.signUrl({ method: "GET", bucket: "reports", key: "q1.txt" });

The minted URL is usable without a bearer token and can never exceed the minting key's scope (a read-only key minting a PUT URL is a 403). See signed URLs.

Bucket notifications

Webhook-only delivery; the per-config signing secret is server-side and never returned (the view carries only hasSecret). Events accept the canonical s3:Object* names or the shorthand 'object-created' / 'object-removed'; filters take { prefix?, suffix? }.

js
await native.setBucketNotification("reports", {
  webhookUrl: "https://my-app.example.com/hooks/lockwell",
  events: ["object-created", "object-removed"],
  filters: { prefix: "uploads/", suffix: ".pdf" },
});
const cfg = await native.getBucketNotification("reports");
console.log(cfg.configs[0].hasSecret); // the signing secret is never returned
await native.deleteBucketNotification("reports"); // clear

Errors

NativeClient throws NativeError mapped from the API's problem+json: isNativeNotFound (404), isNativeConflict (409), isNativePreconditionFailed (412), isNativeForbidden (403), isNativeUnauthorized (401), isNativeQuotaExceeded (507).

AdminClient (the admin client)

Targets /admin/api/v1/ on the admin listener (never the public S3 port). It authenticates by an admin API bearer token (lockwell admin-token create).

A created or rotated key returns its secret exactly once. Store it on the spot; listKeys never returns

secrets. :::

js
import { AdminClient } from "@kelphect/sdk";

const admin = new AdminClient({
  endpoint: "https://admin.example.com", // admin listener, not the S3 port
  token: process.env.LOCKWELL_ADMIN_TOKEN, // Authorization: Bearer <token>
});

const tenant = await admin.createTenant({ id: "acme", name: "Acme Inc" });

// Mutations accept { dryRun } to preview the plan (sends ?dryRun=true).
const plan = await admin.deleteTenant("acme", { reason: "offboarding", confirm: "acme", dryRun: true });

// The secret is returned exactly once on create/rotate. Store it immediately.
const key = await admin.createKey("acme", { scopes: "read,write,delete" });
console.log(key.secretKey);
MethodSignature
listTenants / getTenantlistTenants() / getTenant(id)
createTenantcreateTenant({ id, name?, dryRun? })
disableTenantdisableTenant(id, { reason, dryRun? })
deleteTenantdeleteTenant(id, { reason, confirm, dryRun? })
getQuota / setQuota / clearQuotasetQuota(id, bytes, { dryRun? })
getUsagegetUsage(id)
listAccounts / createAccountcreateAccount(id, { name, dryRun? })
listKeyslistKeys(id) (never returns secrets)
createKeycreateKey(id, { accountId?, scopes?, expiresAt?, dryRun? })
rotateKeyrotateKey(id, keyId, { scopes?, expiresAt?, dryRun? })
revokeKeyrevokeKey(id, keyId, { reason, dryRun? })
queryAuditqueryAudit({ tenant?, since?, limit? })

scopes follows the verb list (read,write,delete,admin) or resource grammar (op=read,write,delete:bucket=reports:prefix=in/); expiresAt is RFC3339 or YYYY-MM-DD. Errors throw AdminError; use isAdminNotFound(err). See the Admin API reference.

LockwellKit (the app kit)

Composes the Admin and Native clients so an app does not hand-roll tenant-to-key provisioning, per-tenant clients, browser direct upload and download, or webhook verification.

js
import { LockwellKit } from "@kelphect/sdk";

const kit = new LockwellKit({
  admin: { endpoint: "https://admin.example.com", token: process.env.LOCKWELL_ADMIN_TOKEN },
  native: { endpoint: "https://objects.example.com" }, // public listener; creds are per-tenant
});

// 1) Provision: ensure the tenant exists (a pre-existing tenant is NOT an error),
//    mint a FRESH scoped key, optionally create a default bucket. Secret returned ONCE.
const { key } = await kit.provisionTenant("acme", { defaultBucket: "inbox" });
// persist { key.accessKeyId, key.secretKey } in your tenant store

// 2) A per-tenant native client (cached per tenant+accessKeyId).
const client = kit.clientForTenant("acme", { accessKeyId: key.accessKeyId, secretKey: key.secretKey });
await client.putObject("inbox", "hello.txt", "hi");

// 3) Browser direct upload/download. Hand the URL straight to the browser.
const up = await kit.signedUploadUrl(client, "inbox", "photo.jpg", { ttl: 300, contentType: "image/jpeg" });
const down = await kit.signedDownloadUrl(client, "inbox", "photo.jpg", { ttl: 300 });

// 4) Verify an incoming Lockwell webhook (constant-time HMAC-SHA256).
const ok = await kit.verifyWebhook(rawRequestBodyBytes, req.headers["x-lockwell-signature"], secret);

provisionTenant(tenantId, opts?) options: { name?, scopes?, accountId?, expiresAt?, defaultBucket?, bucketVersioning?, bucketScope? }. It mints a data key only (default read,write,delete, or narrowed via bucketScope). A defaultBucket is created with a transient admin-on-that-bucket key that is revoked immediately.

signedUploadUrl and signedDownloadUrl accept either a NativeClient (from clientForTenant) or { accessKeyId, secretKey, tenantId? } creds. clientForTenant is also aliased as forTenant. See the app kit guide.

Edge safety

The default barrel re-exports the S3 Client, whose SigV4 code statically imports node:crypto. On Cloudflare Workers, Vercel Edge, Bun, and Deno, import from the dedicated edge entry instead:

js
import { LockwellKit, NativeClient, AdminClient, verifyWebhook } from "@kelphect/sdk/edge";

@kelphect/sdk/edge re-exports only the node:*-free surface (the NativeClient, AdminClient, LockwellKit, verifyWebhook, sha256ChecksumBase64, RetryPolicy). It deliberately omits the S3 Client, so a bundler produces a bundle with zero node:crypto and no nodejs_compat flag.

The NativeClient itself uses only web globals (fetch, ReadableStream, TextEncoder, btoa, crypto.subtle). For a SHA-256 checksum without node:crypto, sha256ChecksumBase64 computes one with WebCrypto. See edge runtimes.

The default @kelphect/sdk import pulls in node:crypto through the S3 Client. On an edge runtime,

import from @kelphect/sdk/edge, which omits the S3 Client. :::

Retry policy

RetryPolicy mirrors the Go SDK so all three behave identically. RetryPolicy.default() is up to 3 attempts, 100ms base backoff doubling to a 2s cap, with full jitter; RetryPolicy.disabled() attempts every request once. The constructor accepts { maxAttempts?, baseBackoffMs?, maxBackoffMs?, jitter?, rand?, sleep? }.

It retries GET/HEAD/DELETE and any PUT/POST carrying an idempotency key, on transport errors and 5xx/429; a 4xx other than 429 is never retried, and streaming bodies are never retried.

TypeScript

The package ships type declarations (@kelphect/sdk resolves to types/index.d.ts), so endpoint, accessKeyId, secretKey, the PutOptions and GetOptions shapes, the ChecksumAlgorithm union ('CRC32' | 'CRC32C' | 'CRC64NVME' | 'SHA1' | 'SHA256'), and the result types are all typed. The edge entry has its own types/edge.d.ts.

Not supported (by design)

No presigned PUT/HEAD/DELETE on the S3 Client (presign is GET-only). Everywhere: no public-bucket or anonymous access without a token or signed URL, no SSE-KMS/SSE-C, no IAM/STS/bucket-policy, no website/tiering/Select/Object-Lambda, and webhook is the only notification target (SNS/SQS/Lambda are a 501 non-goal).

Released under the Apache-2.0 License. License