# Node SDK

:::caution LNW/1 migration
The package-root `NativeClient` now uses the server-only binary LNW/1 transport
on Node 22+. Existing HTTP/JSON callers must migrate their constructor and method
names, or temporarily import `LegacyNativeClient` from
`@kelphect/sdk/legacy-native`. The S3 `Client` and JSON `AdminClient` remain
separate compatibility and control-plane surfaces. Browser and Edge bundles
cannot import the primary LNW/1 entry.
:::

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 and Promise-based. The package-root data plane bundles no second transport implementation: it
uses the peer `@kelphect/sdk-native` core. The S3 entry uses `node:crypto` plus global `fetch`; the dedicated `/edge`
entry uses only web globals such as `crypto.subtle` and `ReadableStream`.

::: info Early access
Lockwell is closed source during early access. The package versions on this page are historical builds; early-access
customers receive current packages, the deployment repository and a licence with their access. Request access at
[hello@rusticstack.com](mailto:hello@rusticstack.com). An MIT-licensed source release is planned.
:::

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 @kelphect/sdk-native
```

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

> The package is published privately as `@kelphect/sdk` on GitHub Packages. The npm scope matches the GitHub owner
> namespace and is the supported package name for private app development.

## Exports

| Export                                        | What it is                                                               |
| --------------------------------------------- | ------------------------------------------------------------------------ |
| `Client`                                      | the S3 (SigV4 + XML) data-plane client. Node-only.                       |
| `NativeClient`                                | the canonical LNW/1 binary data-plane client. Node-only.                 |
| `LegacyNativeClient`                          | explicit HTTP/JSON migration client (`/legacy-native`).                  |
| `AdminClient`                                 | the JSON admin client (admin listener).                                  |
| `LockwellKit`                                 | the high-level app kit.                                                  |
| `verifyWebhook`                               | standalone, edge-safe webhook verification.                              |
| `ErpScopes` / `StorageProfiles` / `ErpErrors` | pure ERP scoping, storage-recipe, and safe error-classification helpers. |

Error helpers ship alongside the clients:

- S3: `APIError` and `isNotFound`.
- LNW/1: `LockwellError`, `ProtocolError`, `TransportError`, `ServiceError`, `CancelledError`, and `DeadlineExceededError`.
- Legacy HTTP/JSON: `NativeError` and `isNative*` helpers from `@kelphect/sdk/legacy-native`.
- Admin: `AdminError` and `isAdminNotFound`.

Plus `RetryPolicy`, `TimeoutError`, `sha256ChecksumBase64`, `computeChecksumBase64`, `checksumHeaderName`,
`CHECKSUM_ALGORITHMS`, `buildPresignedGetUrl`, `buildPresignedObjectUrl`, `urlForKey`, `createNodeFetch`, and
`WEBHOOK_SIGNATURE_HEADER_NAME`.

On edge runtimes, import from [`@kelphect/sdk/edge`](#edge-safety) instead.

## `Client` (the S3 client)

```ts twoslash
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; PUT, HEAD, and DELETE helpers are also available.
const url = client.presignGetObject("reports", "q1.txt", 900);
```

Path-style addressing is the default. A clean reverse-proxy prefix such as `https://objects.example.com/lockwell` is
preserved in normal and presigned request paths and participates in SigV4 signing. Already-mounted `/api/v1` and
`/admin/api/v1` suffixes are normalized once, and traversal, encoded separators, malformed escapes, and unsafe interior
path segments are rejected. Set `virtualHostedStyle: true` only when the endpoint has wildcard bucket DNS; normal
requests and presigned URLs then put the bucket, including dotted bucket names, in the signed host.

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 include `endpoint`, `accessKeyId`, `secretKey`, optional `fetch`, `userAgent`, `now`, `retry`,
`timeoutMs`, `signal`, and `virtualHostedStyle`.

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

| Method                | Signature                                                             |
| --------------------- | --------------------------------------------------------------------- |
| `createBucket`        | `createBucket(bucket, opts?)` (opts `{ objectLock: { mode, days } }`) |
| `headBucket`          | `headBucket(bucket)`                                                  |
| `deleteBucket`        | `deleteBucket(bucket)`                                                |
| `putBucketVersioning` | `putBucketVersioning(bucket, status)` (`'Enabled'` / `'Suspended'`)   |
| `getBucketVersioning` | `getBucketVersioning(bucket)` (returns `''` when never enabled)       |

### Objects

| Method            | Signature                                                                                      |
| ----------------- | ---------------------------------------------------------------------------------------------- |
| `putObject`       | `putObject(bucket, key, body, opts?)`                                                          |
| `putObjectStream` | `putObjectStream(bucket, key, source, checksumAlgorithm, opts?)`                               |
| `getObject`       | `getObject(bucket, key, opts?)` (fully buffered `body`)                                        |
| `getObjectStream` | `getObjectStream(bucket, key, opts?)` (`body` is the `ReadableStream`; `readAll()` buffers it) |
| `headObject`      | `headObject(bucket, key, opts?)`                                                               |
| `deleteObject`    | `deleteObject(bucket, key, opts?)` (`opts.versionId`)                                          |
| `deleteObjects`   | `deleteObjects(bucket, objects, opts?)` (batch &lt;= 1000)                                     |
| `copyObject`      | `copyObject(srcBucket, srcKey, srcVersionId, dstBucket, dstKey, opts?)`                        |

`putObject` options:

| Option                       | Effect                                                                        |
| ---------------------------- | ----------------------------------------------------------------------------- |
| `contentType`                | Sets `Content-Type`.                                                          |
| `metadata`                   | Object of user metadata, stored as `x-amz-meta-*`.                            |
| `idempotencyKey`             | The signed `x-lockwell-idempotency-key`; makes the write replay-safe.         |
| `serverSideEncryption: true` | Requests SSE-S3 at rest.                                                      |
| `checksumAlgorithm`          | One of `CHECKSUM_ALGORITHMS`; the SDK computes and sends the verified digest. |
| `objectLockMode`             | `'GOVERNANCE'` or `'COMPLIANCE'`, set on PUT.                                 |
| `objectLockRetainUntil`      | RFC3339 retain-until date.                                                    |
| `objectLockLegalHold`        | `true`/`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

| Method                 | Signature                                                                                            |
| ---------------------- | ---------------------------------------------------------------------------------------------------- |
| `listObjectsV2`        | `listObjectsV2(bucket, opts?)` (`prefix`, `delimiter`, `startAfter`, `continuationToken`, `maxKeys`) |
| `listObjects`          | `listObjects(bucket, opts?)` (v1: `marker` pagination)                                               |
| `listObjectVersions`   | `listObjectVersions(bucket, opts?)` (`keyMarker`, `versionIdMarker`, ...)                            |
| `listMultipartUploads` | `listMultipartUploads(bucket, opts?)`                                                                |
| `listParts`            | `listParts(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 `uploadPartStream(bucket, key, uploadId, partNumber, source, checksumAlgorithm, opts?)`,
`uploadPartCopy(srcBucket, srcKey, srcVersionId, dstBucket, dstKey, uploadId, partNumber, byteRange, opts?)`, and
`abortMultipartUpload(bucket, key, uploadId)`. When you declare a `checksumAlgorithm` on create, pass it back on every
part, and the composite checksum returns on complete. SSE-C uses the same raw 32-byte `sseCustomerKey` at create, part,
and completion; copy parts separately accept `copySourceSseCustomerKey`.

### 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). `presignPutObject`, `presignHeadObject`, and
`presignDeleteObject` cover the other supported object methods; for a native signed write URL use the
[native `signUrl`](#signed-urls-get-and-put).

### 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.

Streaming PUT/GET and multipart part options accept an awaited `onProgress` callback. `TransferProgress` reports
direction, cumulative bytes, `chunkBytes`, `totalBytes` (`null` when unknown), and `partNumber` for a part. The callback
is awaited before the next chunk is read/enqueued, preserving backpressure; a rejection aborts the request and cancels
its source. `onResponseMetadata` is an additive client option for successful request-id/trace headers. One-shot streams
are not transparently resumed; use range/ETag or multipart list/abort and application-owned atomic file handling for
recovery.

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.

`NativeError` and `AdminError` preserve RFC 9457 `type`, `title`, `detail`, `instance`, and unknown extension members
alongside `code`, `statusCode`, and `requestId`. Treat the HTTP status and exact machine code as authoritative; never
branch on the human message.

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

## `NativeClient` (canonical LNW/1 data plane)

The package-root `NativeClient` is the server-only binary LNW/1 client backed by
the shared `@kelphect/sdk-native` TypeScript core. It requires Node 22+ (or Bun
1.4+ when using the shared runtime package) and preserves bounded frames,
TLS/mTLS verification, multiplexing, flow-control backpressure, deadlines,
cancellation, replay-safe retries, redacted diagnostics, and typed response
metadata.

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

const native = new NativeClient({
  host: "objects.example.com",
  port: 9444,
  tls: { ca: process.env.LOCKWELL_CA_PEM },
  credentials: { accessKeyId, secretKey },
});

await native.putObject({
  bucket: "reports",
  key: "daily.json",
  body: new TextEncoder().encode("{}"),
  contentLength: 2,
  options: { idempotencyKey: "daily-2026-08-31" },
});
const response = await native.getObject({ bucket: "reports", key: "daily.json" });
for await (const chunk of response.body) {
  // consume the bounded stream; cancellation propagates to LNW/1
  void chunk;
}
await native.close();
```

The typed client includes buckets, objects, ranges, metadata, checksums,
pagination, copy, multipart, versioning/delete markers, Object Lock retention,
legal hold, CORS, notifications, and signed capabilities. Use
`createLockwellClient(config)` when selecting the Node or Bun connector
explicitly. The JSON `AdminClient` remains a separate control plane; no admin
wire opcodes are added to LNW/1.

## `LegacyNativeClient` (HTTP/JSON migration client)

The legacy JSON data plane at `/api/v1/` remains available only for bounded
migrations. New server code must use the package-root LNW/1 `NativeClient`.

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 { LegacyNativeClient, sha256ChecksumBase64 } from "@kelphect/sdk/legacy-native";

const native = new LegacyNativeClient({
  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

| Method                                                 | Signature                                                      |
| ------------------------------------------------------ | -------------------------------------------------------------- |
| `listBuckets`                                          | `listBuckets()`                                                |
| `createBucket`                                         | `createBucket(bucket, opts?)` (`{ versioning?, objectLock? }`) |
| `getBucket`                                            | `getBucket(bucket)`                                            |
| `deleteBucket`                                         | `deleteBucket(bucket)`                                         |
| `getBucketVersioning` / `setBucketVersioning`          | versioning state (`'Enabled'`/`'Suspended'`)                   |
| `setBucketCors` / `getBucketCors` / `deleteBucketCors` | browser CORS rules                                             |
| `putObject`                                            | `putObject(bucket, key, body, opts?)` (streaming)              |
| `getObject` / `getObjectStream`                        | buffered / streaming download (`{ range?, versionId? }`)       |
| `headObject`                                           | `headObject(bucket, key, opts?)`                               |
| `deleteObject`                                         | `deleteObject(bucket, key, opts?)`                             |
| `listObjects`                                          | `listObjects(bucket, opts?)`                                   |
| `batchDeleteObjects`                                   | `batchDeleteObjects(bucket, objects, opts?)` (&lt;= 1000)      |
| `copyObject`                                           | `copyObject(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? }
```

### Tags, retention, legal hold, versions, multipart

`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.

### Bucket CORS

`setBucketCors` accepts the native shape (`{ rules: [...] }`), a single rule, or an array of rules:

```js
await native.setBucketCors("reports", {
  rules: [
    {
      allowedOrigins: ["https://app.example.com"],
      allowedMethods: ["GET", "HEAD", "PUT"],
      allowedHeaders: ["content-type"],
      exposeHeaders: ["ETag"],
      maxAgeSeconds: 600,
    },
  ],
});

const cors = await native.getBucketCors("reports");
await native.deleteBucketCors("reports");
```

CORS is browser policy, not authorization. Changing it is an admin-scoped bucket operation; app code should usually use
`kit.configureBucketCors(...)` or `provisionTenant(..., { bucketCors })` so the admin-capable key is transient.

### 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](/guide/signed-urls).

### Bucket notifications

Webhook-only delivery; a new config ID returns its signing secret exactly once (GET/update carry only `hasSecret`).
Events accept the canonical `s3:Object*` names or the shorthand `'object-created'` / `'object-removed'`; filters take
`{ prefix?, suffix? }`.

```js
const created = await native.setBucketNotification("reports", {
  id: "reports-events",
  webhookUrl: "https://my-app.example.com/hooks/lockwell",
  events: ["object-created", "object-removed"],
  filters: { prefix: "uploads/", suffix: ".pdf" },
});
console.log(created.configs[0].signingSecret); // shown once; store securely
const cfg = await native.getBucketNotification("reports");
console.log(cfg.configs[0].hasSecret); // signingSecret is omitted on GET
await native.deleteBucketNotification("reports"); // clear
```

### Errors

`LegacyNativeClient` 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`).

::: warning 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);
```

| Method                                 | Signature                                                     |
| -------------------------------------- | ------------------------------------------------------------- |
| `listTenants` / `getTenant`            | `listTenants()` / `getTenant(id)`                             |
| `createTenant`                         | `createTenant({ id, name?, dryRun? })`                        |
| `disableTenant`                        | `disableTenant(id, { reason, dryRun? })`                      |
| `deleteTenant`                         | `deleteTenant(id, { reason, confirm, dryRun? })`              |
| `getQuota` / `setQuota` / `clearQuota` | `setQuota(id, bytes, { dryRun? })`                            |
| `getUsage`                             | `getUsage(id)`                                                |
| `listAccounts` / `createAccount`       | `createAccount(id, { name, dryRun? })`                        |
| `listKeys`                             | `listKeys(id)` (never returns secrets)                        |
| `createKey`                            | `createKey(id, { accountId?, scopes?, expiresAt?, dryRun? })` |
| `rotateKey`                            | `rotateKey(id, keyId, { scopes?, expiresAt?, dryRun? })`      |
| `revokeKey`                            | `revokeKey(id, keyId, { reason, dryRun? })`                   |
| `queryAudit`                           | `queryAudit({ tenant?, since?, limit? })`                     |

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

## `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 });

// Or update CORS later with another transient admin-scoped key.
await kit.configureBucketCors("acme", "inbox", {
  rules: [{ allowedOrigins: ["https://app.example.com"], allowedMethods: ["GET", "HEAD", "PUT"] }],
});

// 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?, bucketCors? }`. 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; `bucketCors` uses that same transient key before revoke.

`signedUploadUrl` and `signedDownloadUrl` accept either a `LegacyNativeClient` (from `clientForTenant`) or
`{ accessKeyId, secretKey, tenantId? }` creds. `configureBucketCors` mints and revokes its own transient admin-scoped
bucket key. `clientForTenant` is also aliased as `forTenant`. See [the app kit guide](/guide/app-kit).

The retry-safe adoption helpers are `ensureTenant`, `ensureKey`, and `ensureTenantProvisioning`. They reuse only a
matching active key anchored by `externalRef`; a reused key never pretends to have a recoverable secret. The ERP helper
families derive opaque tenant/company/purpose paths and least-privilege scopes, produce explicit storage recipes, and
classify native/admin failures without copying unsafe server messages. They do not choose legal policy or persist ERP
mappings for you.

`NativeClient` and `AdminClient` also expose `healthz()` and `readyz()` with per-call `signal`/`timeoutMs` controls and
typed readiness components. Probe calls do not send data-plane or admin credentials.

## 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, LegacyNativeClient, AdminClient, verifyWebhook } from "@kelphect/sdk/edge";
```

`@kelphect/sdk/edge` re-exports only the `node:*`-free compatibility surface (the `LegacyNativeClient`, `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 `LegacyNativeClient` 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](/guide/edge-runtimes).

::: warning The default `@kelphect/sdk` import pulls in `node:crypto` through the S3 `Client`. On an edge runtime,
use `@kelphect/sdk/edge` instead, which omits the S3 `Client`. :::

## Retry policy

`RetryPolicy` uses the shared replay-safety rules, while language defaults and callback shapes differ where the
[capability index](/reference/sdk-capabilities) says so. `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)

The S3 `Client` presigns GET/PUT/HEAD/DELETE. Everywhere: no public-bucket or anonymous access without a token or signed
URL, no SSE-KMS, 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).

The Lockwell server and this first-party client support SSE-C and copy-source SSE-C through `sseCustomerKey` and
`copySourceSseCustomerKey` raw 32-byte options on object, copy, and multipart operations.
