--- url: /guide/getting-started.md description: >- Run Lockwell with Docker, install an SDK, store your first file, and hand the browser a direct upload URL, in about five minutes. --- # Getting started Lockwell is an object store you host yourself. Your app talks to it through one SDK: files in, files out, per-customer isolation built in. ::: warning Historical package line and release gate The `0.2.2` SDK coordinates used in this quickstart are historical private artifacts, not a current approved release. Do not use them for TangibleShift or another commercial deployment; wait for the post-B-010/B-013 release handoff and the required written commercial grant. ::: This page gets you to a working result: run the server, store a file, read it back, and let a browser upload directly. Concepts can wait; when a term is new, [Concepts in plain words](/guide/concepts) defines it in a sentence. ## 1. Run Lockwell One container, one volume, no external database. ```sh git clone https://github.com/RusticStack/lockwell-deploy.git cd lockwell-deploy cp .env.example .env docker compose up -d --build ``` Before `up`, open `.env` and replace the two `CHANGE_ME` values with long random strings. They become your first access key, and the container refuses to start while the placeholders are still in place. Check it is alive: ```sh curl -fsS http://localhost:9000/health ``` You now have two listeners and one credential: * `http://localhost:9000`: the public listener (object I/O) * `http://localhost:9001`: the admin listener (management, kept private) * the access key from `.env`, already bootstrapped with full data access Already running Lockwell somewhere? Skip ahead and use your own endpoint and key. ## 2. Install the SDK ::: code-group ```sh [Node] npm i @kelphect/sdk ``` ```sh [Go] go get github.com/KelpHect/lockwell@v0.2.2 ``` ```sh [Java] # com.lockwell:lockwell-sdk on GitHub Packages. See Installation for the # repository + auth setup. ``` ::: Registries, prerequisites, and client options live on the [Installation](/guide/installation) page. This quickstart intentionally uses the HTTP-native JSON client. LNW/1 is an experimental, opt-in binary transport on a separate TLS 1.3 listener; use the [Native Wire guide](/guide/native-wire) and its \[server-only SDK guides] (/sdks/bun-native) after the listener and runtime have passed their qualification gates. ## 3. Store a file and read it back Point the native client at the public listener with the key from `.env`. It mints and refreshes its own short-lived token; you never touch auth plumbing. ::: code-group ```ts [Node] import { NativeClient } from "@kelphect/sdk"; const native = new NativeClient({ endpoint: "http://localhost:9000", accessKeyId: process.env.LOCKWELL_ROOT_ACCESS_KEY_ID, secretKey: process.env.LOCKWELL_ROOT_SECRET_KEY, }); await native.createBucket("inbox"); await native.putObject("inbox", "hello.txt", "hi there", { contentType: "text/plain" }); const got = await native.getObject("inbox", "hello.txt"); console.log(got.body.toString()); // "hi there" ``` ```go [Go] package main import ( "context" "io" "log" "os" "strings" "github.com/KelpHect/lockwell/pkg/lockwellnative" ) func main() { ctx := context.Background() native, err := lockwellnative.New("http://localhost:9000", os.Getenv("LOCKWELL_ROOT_ACCESS_KEY_ID"), os.Getenv("LOCKWELL_ROOT_SECRET_KEY")) if err != nil { log.Fatal(err) } if _, err := native.CreateBucket(ctx, lockwellnative.CreateBucketInput{Name: "inbox"}); err != nil { log.Fatal(err) } if _, err := native.PutObject(ctx, lockwellnative.PutObjectInput{ Bucket: "inbox", Key: "hello.txt", Body: strings.NewReader("hi there"), ContentType: "text/plain", }); err != nil { log.Fatal(err) } obj, err := native.GetObject(ctx, lockwellnative.GetObjectInput{Bucket: "inbox", Key: "hello.txt"}) if err != nil { log.Fatal(err) } defer obj.Close() body, _ := io.ReadAll(obj) log.Println(string(body)) // "hi there" } ``` ```java [Java] import com.lockwell.sdk.nativeapi.LockwellNativeClient; import com.lockwell.sdk.nativeapi.NativeTypes.GetResult; import com.lockwell.sdk.nativeapi.NativeTypes.PutOptions; var nativeClient = LockwellNativeClient.builder() .endpoint("http://localhost:9000") .accessKeyId(System.getenv("LOCKWELL_ROOT_ACCESS_KEY_ID")) .secretKey(System.getenv("LOCKWELL_ROOT_SECRET_KEY")) .build(); nativeClient.createBucket("inbox"); nativeClient.putObject("inbox", "hello.txt", "hi there".getBytes(), new PutOptions().contentType("text/plain")); try (GetResult got = nativeClient.getObject("inbox", "hello.txt")) { got.body().transferTo(System.out); // "hi there" } ``` ::: That is a private, encrypted object store taking writes. Streaming, ranges, listing, versions, and the rest of the object API start at [Upload & download](/guide/data-operations). ## 4. Let the browser upload directly Sign a short-lived PUT URL on your server and hand only the URL to the browser. The file bytes go straight to Lockwell, and your key never leaves the server. ::: code-group ```ts [Node] // Server-side: the signed URL comes back as a path; join it to the endpoint. const signed = await native.signUrl({ method: "PUT", bucket: "inbox", key: "photo.jpg", ttlSeconds: 300, }); const url = new URL(signed.url, "http://localhost:9000").toString(); // Browser-side: await fetch(url, { method: "PUT", body: file }); ``` ```go [Go] // SignURL returns an absolute URL in Go. url, err := native.SignURL(ctx, lockwellnative.SignURLInput{ Method: "PUT", Bucket: "inbox", Key: "photo.jpg", TTLSeconds: 300, }) if err != nil { log.Fatal(err) } // Return url to the browser, which PUTs the file body to it. ``` ```java [Java] // The signed URL comes back as a path; join it to the endpoint. var signed = nativeClient.signUrlResult("PUT", "inbox", "photo.jpg", 300); var url = java.net.URI.create("http://localhost:9000").resolve(signed.url()).toString(); // Return url to the browser, which PUTs the file body to it. ``` ::: The URL is bound to one method and one object, expires on its TTL, and can never exceed the signing key's scope. The full flow, including the download direction, is on [Signed URLs](/guide/signed-urls). ## That's it You ran the server, stored a file, read it back, and took a browser upload. One SDK, no second store, no glue code. ## Going multi-tenant The quickstart used the bootstrap root key. For a real app you give each customer their own tenant and a key scoped to it, so customers are isolated by the credential itself. That is the job of the **admin API** and the **app kit** (`provisionTenant`, `clientForTenant`). The admin API needs an admin token, which is minted offline because the CLI and the daemon cannot hold the embedded store at the same time: ```sh docker compose stop lockwell docker compose run --rm lockwell lockwell admin-token create --name my-app --role owner docker compose start lockwell # prints an lwadm_... token exactly once. Store it as LOCKWELL_ADMIN_TOKEN. ``` Then follow [The app kit](/guide/app-kit) for the provision-and-go flow, and [Tenancy & auth](/guide/tenancy-and-auth) for the model behind it. ## Next steps * [Installation](/guide/installation). Per-language registries, client options, and retry setup. * [Upload & download](/guide/data-operations). The full object API. * [The app kit](/guide/app-kit). Multi-tenant provisioning with near-zero glue. * [Signed URLs](/guide/signed-urls) and [Webhooks](/guide/webhooks). Browser-direct I/O and events. * [The three surfaces](/guide/the-three-surfaces). How S3, native, and admin fit together. * [Native Wire (LNW/1)](/guide/native-wire). Binary transport setup, limits, retries, and rollback. * [Deployment](/guide/deployment). TLS, the master key, backups, and production posture. --- --- url: /guide/concepts.md description: >- Plain-words definitions of the handful of terms the Lockwell docs use, from tenants and scoped keys to surfaces, bearer tokens, and signed URLs. --- # Concepts in plain words The docs lean on a small set of terms. Here is what each one means, in plain words, with a link to the page that goes deeper. Skim this once and the rest of the docs read faster. ## Tenant One customer, workspace, or project in your app. Every bucket and key belongs to exactly one tenant, and a tenant can never see another tenant's data. When the docs say "org", they mean the tenant. Deeper: [Tenancy & auth](/guide/tenancy-and-auth). ## Access key The long-lived credential a server holds: an id plus a secret. The secret is shown once at creation and never again, so your app stores it in its own database. ## Scoped key An access key restricted to what it may do: which operations (`read`, `write`, `delete`), and optionally one bucket or key prefix. A leaked read-only key cannot write; a key scoped to one bucket cannot touch another. Scoping is how you give each part of your system the least access it needs. Deeper: [scoped access keys](/guide/tenancy-and-auth#scoped-access-keys). ## Surface One of the three ways to talk to the same server: the **S3 API** (for existing S3 tools), the **native API** (JSON, made for app code), and the **admin API** (management). They are different doors into one building; the security checks inside are identical. Deeper: [The three surfaces](/guide/the-three-surfaces). ## Bearer token A short-lived pass the native client mints from your access key and sends on each request. You never handle it: the SDK mints, caches, and refreshes it for you. If the underlying key is revoked, the token stops working too. ## Admin token A separate credential (`lwadm_...`) for the management API: creating tenants, minting keys, setting quotas, reading audit logs. It lives on your server, never in a browser, and carries a role (`owner`, `operator`, or `viewer`). Deeper: [Admin API](/reference/admin-api). ## Signed URL A link that grants one action on one object for a few minutes, with the authorization baked into the URL itself. Your server mints it; the browser uses it with no credential. This is how browsers upload and download directly without your app proxying the bytes. Deeper: [Signed URLs](/guide/signed-urls). ## The app kit `LockwellKit`, a helper that composes the admin and native clients into the five jobs every multi-tenant app needs: provision a tenant, get a per-tenant client, configure browser CORS, sign browser URLs, and verify webhooks. It is convenience only; it adds no new server behavior. Deeper: [The app kit](/guide/app-kit). ## Bucket, object, version Same meaning as S3. A **bucket** is a named container inside a tenant. An **object** is a file plus its metadata, addressed by a key like `invoices/2026/03.pdf`. With versioning on, every write keeps the previous **version** instead of overwriting it. Deeper: [Upload & download](/guide/data-operations) and [Versioning](/guide/versioning). ## Idempotency key A label you attach to a write so that retrying the same request applies it once instead of twice. Pair it with a checksum on the native API. Deeper: [Conditional writes & idempotency](/guide/conditional-writes). ## Webhook An HTTP POST Lockwell sends to your endpoint when an object is created or removed, signed so you can verify it really came from your server. Deeper: [Webhooks](/guide/webhooks). --- --- url: /guide/installation.md description: >- Install Lockwell's Go, Node, Java, Native Wire, SolidStart, and Spring integrations, then configure credentials, retries, TLS, and runtime boundaries. --- # Installation Install a first-party Lockwell SDK and point it at a running daemon. Go, Node, and Java ship S3, HTTP-native JSON, and Admin clients. The opt-in Native Wire packages add a binary LNW/1 transport for qualified server runtimes; they never silently switch protocols. Start with the [surface guide](/guide/the-three-surfaces) and [Native Wire setup](/guide/native-wire). ::: warning Historical package line and release gate The `0.2.2` coordinates shown below are historical private package/tag artifacts, not a current approved release. They predate the selected PolyForm distribution payload and must not be used for TangibleShift or another commercial deployment. Wait for the post-B-010/B-013 release handoff and the required written commercial grant; these examples do not by themselves prove publication or legal approval. ::: ## Prerequisites You need a running `lockwelld` with two listeners reachable from your app: | Listener | Carries | Example | | ---------- | ----------------------------------------------- | ----------------------- | | **public** | the S3 API, native JSON API (`/api/v1/`), and optional LNW listener | `http://localhost:9000` / `127.0.0.1:9444` | | **admin** | the JSON Admin API (`/admin/api/v1/`) | `http://localhost:9001` | Endpoints come from your deployment. See [Deployment](/guide/deployment) to stand one up. Credentials come from Lockwell itself: * An **admin API token** (`lwadm_...`) is minted offline on the server host with `lockwell admin-token create --role owner`. Use it for the Admin API and the app kit. * **Access keys** (an `accessKeyId` + `secretKey`) are minted **through** the Admin API or the app kit's `provisionTenant`, per tenant. Use them for the S3 and native data planes. See [Tenancy and auth](/guide/tenancy-and-auth). ## Node ```sh npm i @kelphect/sdk ``` Requires **Node.js 20 or newer** (global `fetch`, `crypto`, `ReadableStream`). The package ships both ESM and CommonJS, so both styles work: ```js import { Client, NativeClient, AdminClient, LockwellKit } from "@kelphect/sdk"; // ESM const { Client } = require("@kelphect/sdk"); // CJS ``` ::: tip GitHub Packages The package is published privately as `@kelphect/sdk` on the GitHub Packages registry. A one-time auth setup is required. Put a classic GitHub PAT with `read:packages` in `NODE_AUTH_TOKEN` and point the `@kelphect` scope at GitHub Packages in your `.npmrc`. ::: ### Edge runtimes On Cloudflare Workers, Vercel Edge, Bun, or Deno, import from the dedicated edge entry instead of the default barrel: ```js import { NativeClient, AdminClient, LockwellKit, verifyWebhook } from "@kelphect/sdk/edge"; ``` `@kelphect/sdk/edge` re-exports only the `node:*`-free surface (the native client, admin client, app kit, `verifyWebhook`, `RetryPolicy`, `sha256ChecksumBase64`), so a bundler produces a bundle with zero `node:crypto` and no `nodejs_compat` flag. The S3 `Client` is deliberately not exported there. Its SigV4 signer is Node-only. See [Edge runtimes](/guide/edge-runtimes). ### Native Wire (Node/Bun servers) For raw LNW/1 binary transport, install the separate server-only package. It is not the same as the historical `@kelphect/sdk/edge` HTTP-native entry: ```sh npm i @kelphect/sdk-native@0.1.0 # or: bun add @kelphect/sdk-native@0.1.0 ``` `@kelphect/sdk-native` requires Node 22+ or Bun 1.4+. Import `@kelphect/sdk-native/node` or `/bun` explicitly when desired; the root condition selects the runtime. Browser/default conditions throw, and there is no JSON/S3 fallback. See the [Native TypeScript guide](/sdks/bun-native) for TLS CA/mTLS, pooling, streaming, and retry configuration. ## Go The retained `v0.x` compatibility module path is `github.com/KelpHect/lockwell`; the canonical repository is [`RusticStack/lockwell`](https://github.com/RusticStack/lockwell). GitHub's transfer redirect preserves existing Go imports. Configure GitHub auth for private modules, then install the released root module and import only the packages you use: ```sh go env -w GOPRIVATE=github.com/KelpHect/* ``` ```sh # All first-party Go SDK packages are in this one released module. go get github.com/KelpHect/lockwell@v0.2.2 ``` Each package's non-test source imports only the standard library (plus a small errors helper), so you pull a lightweight dependency tree. ```go import ( "github.com/KelpHect/lockwell/pkg/lockwellkit" "github.com/KelpHect/lockwell/pkg/lockwelladmin" "github.com/KelpHect/lockwell/pkg/lockwellnative" ) ``` ## Java The coordinate is `com.lockwell:lockwell-sdk`, published to **GitHub Packages** (configure the GitHub Packages repository and a token with `read:packages` in your build, the same as any GitHub-Packages dependency). It requires **JDK 25**. The main artifact owns the binary LNW/1 client; S3 and HTTP/JSON clients remain explicit compatibility surfaces and the Admin client remains a separate JSON control plane. The optional Spring starter supplies only Boot 4.1.1 autoconfiguration for that same wire core. ::: code-group ```xml [Maven] com.lockwell lockwell-sdk 0.2.2 ``` ```groovy [Gradle] dependencies { implementation 'com.lockwell:lockwell-sdk:0.2.2' } ``` ::: The three surfaces live in distinct packages: ```java import com.lockwell.sdk.*; // S3 data plane: LockwellClient import com.lockwell.sdk.nativeapi.*; // native data plane: LockwellNativeClient import com.lockwell.sdk.springwire.*; // binary LNW/1 data plane import com.lockwell.sdk.admin.*; // Admin API: LockwellAdminClient import com.lockwell.sdk.kit.*; // app kit: LockwellKit ``` For Spring Boot 4.1, add `com.lockwell:lockwell-spring-boot-starter` to enable lifecycle-managed LNW/1 sync and async beans. For other Spring or plain JDK applications, construct `LockwellNativeWireClient` directly. All clients are thread-safe. ### Java private Maven setup `com.lockwell:lockwell-sdk` is published privately to GitHub Packages for the `KelpHect/lockwell` repository. Configure the repository in the application build and put credentials in Maven/Gradle settings, not in source. ```xml [Maven repository] github https://maven.pkg.github.com/RusticStack/lockwell ``` ```xml [~/.m2/settings.xml] github x-access-token ${env.GITHUB_PACKAGES_TOKEN} ``` Use a GitHub token with `read:packages` for developer machines, CI, and on-prem build runners. If the consuming repository is private and separate from `KelpHect/lockwell`, the token also needs repository access that can read the private package. In GitHub Actions, write `settings.xml` from secrets and pass the token through the environment; never commit tokens or generated Maven settings. Pin immutable versions (`0.2.2`, not a floating range). Maven/GitHub Packages publish the binary jar, POM, sources jar, and repository-generated checksums for each version. Support bundles and offline install media should mirror those exact artifacts, their checksums, the release tag, and the Lockwell supply-chain evidence (`make supply-chain`: module verification, vulnerability report, and SBOM). The Java SDK itself has no third-party runtime dependency tree for the core, native, admin, or kit clients. Maven Central is deferred for this adoption gate. Until a future release explicitly adds Maven Central publishing, customer-facing and offline TangibleShift builds should use an internal/customer artifact mirror seeded from the vetted GitHub Packages artifact, not a developer laptop cache. ### Spring Boot Native Wire starter Spring services that need LNW/1 install the separate JDK 25-first starter: ```xml com.lockwell lockwell-spring-boot-starter 0.2.2 ``` Enable `lockwell.native-wire.enabled=true`, configure TLS CA/mTLS properties, and keep access-key material in an environment-backed secret store. The starter is compiled with `--release 25`; its health, metrics, async, Object Lock, metadata, and streaming contract is documented in [Spring Boot Native Wire](/sdks/java-spring-wire). It is source/test qualified in this repository, not an assertion that your package mirror has published it. ### Java 25 and API compatibility The SDK is compiled with `--release 25` and supports JDK 25. LNW/1 is a direct binary transport; it is not HTTP/JSON or S3 compatibility wrapping. The starter is compiled against Spring Boot 4.1.1, with a Spring Boot 4.0 external-consumer lane. Java 21 bytecode/runtime compatibility is not claimed. Pin the SDK and Lockwell server/image to the same release line unless a release note says otherwise. The Java SDK wraps the versioned JSON Admin API at `/admin/api/v1` and native API at `/api/v1`; long-lived on-prem installs can compare the server's published OpenAPI documents (`/admin/api/v1/openapi.json`, `/api/v1/openapi.json`) with the SDK version they ship. A mismatch should fail the installer or support preflight before tenant provisioning starts. ## Client options Every client takes the same core inputs (an endpoint plus credentials) and a few optional knobs. The optional ones share names across surfaces. | Option | What it sets | Default | | ----------------------- | --------------------------------------------------------------------- | ------------------------------- | | `httpClient` / `fetch` | The transport. Inject your own to control timeouts, pooling, and TLS. | A per-client default | | `userAgent` | The `User-Agent` header sent on every request. | `lockwell--/0.1` | | `retry` / retry policy | Automatic retry of safe requests on transient failures. | See [Retry setup](#retry-setup) | | `requestTimeout` (Java) | Per-request timeout on the JDK `HttpClient`. | None | Construct an S3 client with a custom transport: ::: code-group ```ts [Node] import { Client } from "@kelphect/sdk"; const s3 = new Client({ endpoint: "http://localhost:9000", accessKeyId: process.env.LOCKWELL_ACCESS_KEY_ID, secretKey: process.env.LOCKWELL_SECRET_KEY, userAgent: "acme-billing/1.4", }); ``` ```go [Go] import ( "net/http" "time" "github.com/KelpHect/lockwell/pkg/lockwellsdk" ) hc := &http.Client{Timeout: 30 * time.Second} s3, err := lockwellsdk.New("http://localhost:9000", lockwellsdk.Credentials{ AccessKeyID: os.Getenv("LOCKWELL_ACCESS_KEY_ID"), SecretKey: os.Getenv("LOCKWELL_SECRET_KEY"), }, lockwellsdk.WithHTTPClient(hc), lockwellsdk.WithUserAgent("acme-billing/1.4"), ) ``` ```java [Java] import com.lockwell.sdk.*; import java.net.http.HttpClient; import java.time.Duration; LockwellClient s3 = LockwellClient.builder() .endpoint("http://localhost:9000") .credentials(new Credentials( System.getenv("LOCKWELL_ACCESS_KEY_ID"), System.getenv("LOCKWELL_SECRET_KEY"))) .httpClient(HttpClient.newHttpClient()) .userAgent("acme-billing/1.4") .requestTimeout(Duration.ofSeconds(30)) .build(); ``` ::: The native and admin clients accept the same `httpClient`/`userAgent` options. The native client also takes `refreshSkewMs` (Node) to control how far ahead of expiry it refreshes the bearer token. ::: warning Secrets never render Clients redact the secret (and, for the native client, the live bearer token) from `toString` / `inspect` / JSON. Logging a client object never leaks credentials. ::: ## Retry setup The S3 clients retry safe requests on transient failures: a transport error, a `5xx`, or a `429`. A request is only ever replayed when replay is safe. * `GET`, `HEAD`, and `DELETE` are idempotent, so they retry freely. * A buffered-body `PUT`/`POST` retries only when it carries an idempotency key, so the server collapses duplicate effects. * Streaming uploads never retry, because the source is already consumed. Backoff is exponential (100ms base, doubling, 2s cap) with full jitter. The defaults differ by language, so set the policy explicitly when it matters: | SDK | S3 client default | Native client | | ---- | ------------------------------------- | ---------------------------------------------------------- | | Go | on (`DefaultRetryPolicy`, 3 attempts) | mints a token and retries once on `401` | | Node | off (1 attempt) | mints a token and retries once on `401` | | Java | off (1 attempt) | on (`RetryPolicy.defaults()`) plus one `401` token re-mint | ::: code-group ```ts [Node] import { Client, RetryPolicy } from "@kelphect/sdk"; const s3 = new Client({ endpoint: "http://localhost:9000", accessKeyId: process.env.LOCKWELL_ACCESS_KEY_ID, secretKey: process.env.LOCKWELL_SECRET_KEY, retry: RetryPolicy.default(), // opt in (3 attempts) // retry: new RetryPolicy({ maxAttempts: 5, baseBackoffMs: 200 }), // tune it // retry: RetryPolicy.disabled(), // the default }); ``` ```go [Go] import "github.com/KelpHect/lockwell/pkg/lockwellsdk" s3, err := lockwellsdk.New(endpoint, creds, lockwellsdk.WithRetryPolicy(lockwellsdk.DefaultRetryPolicy()), // the default // lockwellsdk.WithRetryPolicy(lockwellsdk.RetryPolicy{MaxAttempts: 5, BaseBackoff: 200 * time.Millisecond, MaxBackoff: 2 * time.Second, Jitter: 1.0}), // lockwellsdk.WithRetryPolicy(lockwellsdk.DisabledRetryPolicy()), ) ``` ```java [Java] import com.lockwell.sdk.*; import java.time.Duration; LockwellClient s3 = LockwellClient.builder() .endpoint(endpoint) .credentials(creds) .retryPolicy(RetryPolicy.defaults()) // opt in (3 attempts) // .retryPolicy(RetryPolicy.of(5, Duration.ofMillis(200), Duration.ofSeconds(2), 1.0)) // tune it // .retryPolicy(RetryPolicy.disabled()) // the default .build(); ``` ::: For an idempotency-keyed write to be retried, set the idempotency key on the put. See [Conditional writes and idempotency](/guide/conditional-writes) and [Errors and retries](/guide/errors-and-retries). The Java native client uses the same `RetryPolicy` type and enables `RetryPolicy.defaults()` by default. It retries `GET`, `HEAD`, and `DELETE` automatically, and retries `PUT` or `POST` only when the request carries `Idempotency-Key`. See [Java native client](/sdks/java-native). ## Verify the install A minimal native-client round-trip confirms the endpoint and credentials are wired correctly: ::: code-group ```ts [Node] import { NativeClient } from "@kelphect/sdk"; const native = new NativeClient({ endpoint: "http://localhost:9000", accessKeyId: process.env.LOCKWELL_ACCESS_KEY_ID, secretKey: process.env.LOCKWELL_SECRET_KEY, }); console.log(await native.listBuckets()); ``` ```go [Go] nc, err := lockwellnative.New("http://localhost:9000", os.Getenv("LOCKWELL_ACCESS_KEY_ID"), os.Getenv("LOCKWELL_SECRET_KEY")) if err != nil { log.Fatal(err) } buckets, err := nc.ListBuckets(context.Background()) if err != nil { log.Fatal(err) } log.Println(buckets) ``` ```java [Java] LockwellNativeClient nc = LockwellNativeClient.builder() .endpoint("http://localhost:9000") .accessKeyId(System.getenv("LOCKWELL_ACCESS_KEY_ID")) .secretKey(System.getenv("LOCKWELL_SECRET_KEY")) .build(); System.out.println(nc.listBuckets()); ``` ::: ## Next steps * [Getting started](/guide/getting-started). The full five-minute walkthrough. * [The three surfaces](/guide/the-three-surfaces). Pick the right client. * [Go SDK](/sdks/go), [Node SDK](/sdks/node), [Java SDK](/sdks/java). Per-language reference. --- --- url: /guide/client-configuration.md description: >- Configure Lockwell SDK endpoints, regions, TLS trust, timeouts, retries, cancellation, concurrency, progress, and response correlation in Go, Node, and Java. --- # Client configuration All three SDKs expose S3, native JSON, and admin clients, but the listeners and credentials are deliberately separate. Use the public object endpoint for S3 and native clients; use the private admin endpoint only for admin clients. ## Endpoint and path prefixes An endpoint may include a clean reverse-proxy prefix, such as `https://objects.example.com/lockwell`. The SDK preserves that prefix in normal requests, SigV4 canonical paths, and presigned URLs. Do not append `/api/v1` or `/admin/api/v1` yourself unless the proxy exposes that exact mounted URL; duplicate suffixes are normalized once. User information, query strings, fragments, traversal segments, encoded separators, and malformed escapes are rejected. Path-style S3 addressing is the default. Enable virtual-hosted style only when wildcard bucket DNS and certificates are configured. Dotted bucket names remain part of the signed host. The S3 signing region defaults to `us-east-1`; it must match the server-configured region. Native and admin bearer-token requests do not use a signing region. ## TLS and a private CA Production endpoints should use TLS. A private CA belongs in the injected transport, not in an SDK-wide “skip verify” flag. Never disable certificate verification. ::: code-group ```go [Go] roots, err := x509.SystemCertPool() if err != nil { log.Fatal(err) } pem, err := os.ReadFile("/run/secrets/lockwell-ca.pem") if err != nil || !roots.AppendCertsFromPEM(pem) { log.Fatal("invalid Lockwell CA") } httpClient := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{ MinVersion: tls.VersionTLS12, RootCAs: roots, }}} s3, err := lockwellsdk.New(endpoint, creds, lockwellsdk.WithHTTPClient(httpClient), lockwellsdk.WithRequestTimeout(30*time.Second)) ``` ```ts [Node] import https from "node:https"; import { readFileSync } from "node:fs"; import { Client, createNodeFetch } from "@kelphect/sdk"; const httpsAgent = new https.Agent({ keepAlive: true, ca: readFileSync("/run/secrets/lockwell-ca.pem"), }); const transport = createNodeFetch({ httpsAgent, maxSockets: 64 }); const s3 = new Client({ endpoint, accessKeyId, secretKey, fetch: transport, timeoutMs: 30_000 }); // Call transport.close() during process shutdown. Caller-supplied agents remain caller-owned. ``` ```java [Java] KeyStore trust = KeyStore.getInstance(KeyStore.getDefaultType()); try (InputStream in = Files.newInputStream(Path.of("/run/secrets/lockwell-truststore.p12"))) { trust.load(in, System.getenv("LOCKWELL_TRUSTSTORE_PASSWORD").toCharArray()); } TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); tmf.init(trust); SSLContext ssl = SSLContext.getInstance("TLS"); ssl.init(null, tmf.getTrustManagers(), null); HttpClient http = HttpClient.newBuilder().sslContext(ssl).build(); LockwellClient s3 = LockwellClient.builder() .endpoint(endpoint).credentials(credentials).httpClient(http) .requestTimeout(Duration.ofSeconds(30)).build(); ``` ::: The JVM-wide alternative is `-Djavax.net.ssl.trustStore=/path/lockwell-truststore.p12` plus `-Djavax.net.ssl.trustStorePassword=...`; prefer an injected `HttpClient` when one process talks to services with different trust roots. ## Timeouts, retries, and cancellation Treat the per-attempt timeout and the caller deadline as different budgets. A retryable operation can consume several attempt timeouts plus backoff; a caller deadline or cancellation stops the whole operation. | Language | Attempt timeout | Whole-operation cancellation | Retry default | | -------- | ----------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | Go | `WithRequestTimeout` | `context.Context` deadline/cancel | S3/native/admin use their documented policy; disable explicitly when needed | | Node | `timeoutMs` (bounded to 30 seconds) | caller-owned `AbortSignal` | S3 one attempt; native/admin three attempts | | Java | `.requestTimeout(Duration)` | synchronous interruption/HTTP state; `CompletableFuture.cancel` for async wrapper | S3 opt-in; native/admin default retries | Retries are limited to transient transport failures, 429, and bounded 5xx responses when the request is replay-safe. Buffered writes need an idempotency key; one-shot streams and ambiguous admin mutations are not replayed. Authentication, authorization, checksum, retention, legal-hold, quota, and ordinary 4xx failures are not automatic retry candidates. ## Progress and streaming recovery Progress callbacks run synchronously or are awaited, so a slow callback applies backpressure. Callback failure cancels the request. Go reports cumulative bytes and a known/unknown total. Node and Java also report direction, chunk bytes, and multipart part number where applicable. None of the SDKs claims transparent stream resume: recover with explicit range reads or multipart list/abort APIs and an application-owned checkpoint. ## Response metadata and audit correlation Configure `WithResponseMetadata`, `onResponseMetadata`, or `responseMetadataListener` to observe successful `requestId`, optional Amazon request id, and optional `traceparent`. Errors carry their request id on the typed error. Store these identifiers beside an application job id; do not put secrets, bearer tokens, signed URL query strings, or object bytes in logs. ## Concurrency and ownership Clients are safe for concurrent use. Go clients may be shared between goroutines; Node clients share token minting and pooled transports; Java clients are thread-safe. Individual paginator/iterator instances and streaming bodies are operation-owned and should not be advanced or consumed concurrently. Close download bodies and any transport/executor that the application owns. ## Related * [Errors and retries](/guide/errors-and-retries) * [Operations and observability](/guide/operations-and-observability) * [SDK capability index](/reference/sdk-capabilities) --- --- url: /guide/the-three-surfaces.md description: >- One Lockwell server exposes S3, native, and Admin interfaces over one shared domain pipeline; Native Wire is an opt-in binary transport for the native data plane. --- # The three surfaces One `lockwelld` server exposes one multi-tenant, encrypted object store through three interfaces. The S3 and native interfaces are object data planes with different wire protocols and auth. **LNW/1 is an alternate binary transport for the native data plane, not a fourth domain surface.** The Admin API is a control plane for the tenant, key, quota, and audit configuration that those data planes enforce. S3 and native object operations, plus authenticated object workflows in the Admin Web UI, flow through the shared object coordinator and storage pipeline. The JSON Admin API changes authoritative configuration through the same domain services; it does not expose a third object-I/O route. None of the surfaces enables public or anonymous access. ```mermaid flowchart TB s3c["S3 clients and tools"] -->|SigV4| S3["S3 API"] app["Your app or browser"] -->|Bearer token| NAT["Native API /api/v1/ or LNW/1"] adm["Admin tooling"] -->|Admin token| ADM["Admin API /admin/api/v1/"] S3 --> PIPE NAT --> PIPE ADM --> CTRL["Control plane: tenants, keys, quota, audit"] CTRL --> PIPE PIPE["Object pipeline: scope, policy, encryption, dedup, quota, retention, audit"] --> STORE[("Encrypted object store")] ``` | Surface | Mount | Listener | Auth | Use it for | | --------------------- | ---------------- | -------- | ------------------------------------------------- | ---------------------------------------------- | | **S3 data plane** | `/` (S3 routes) | public | SigV4 (access key) | Drop-in for existing S3 clients and tools | | **Native data plane** | `/api/v1/` (HTTP JSON) or configured LNW/1 listener | public | bearer token (HTTP) or access-key proof (LNW), scoped to the tenant | Your app's object I/O; LNW is server-only and explicit | | **Admin API** | `/admin/api/v1/` | admin | admin token (`lwadm_`) + RBAC | Provisioning tenants, keys, quotas, audit | ## S3 data plane (SigV4) The S3-compatible API: SigV4 auth, path-style and virtual-host addressing, versioning, multipart, conditional writes, Object Lock, tagging, and lifecycle. Point any S3 client at the public listener: the AWS SDKs, `aws s3`, or your existing S3 tooling. Lockwell also ships a first-party S3 client per language that is a native-first drop-in (idempotent writes, CRC32/CRC32C/CRC64NVME checksums, SSE-S3, streaming). ::: tip S3 object presigning The S3 client presigns **GET, PUT, HEAD, and DELETE**. The native client separately mints native signed GET/PUT URLs (below). ::: ```ts import { Client } from "@kelphect/sdk"; const s3 = new Client({ endpoint: "http://localhost:9000", accessKeyId: process.env.LOCKWELL_ACCESS_KEY_ID, secretKey: process.env.LOCKWELL_SECRET_KEY, }); await s3.putObject("my-bucket", "report.txt", Buffer.from("hello")); const url = s3.presignGetObject("my-bucket", "report.txt", 900); ``` The full S3 operation matrix is the [S3 operations reference](/reference/s3-operations). Use the S3 surface when you already have S3 code or tools, or want strict S3 semantics. For a new app, prefer the native surface. ## Native JSON data plane (`/api/v1/`) A first-class JSON/HTTP object surface on the same public listener as S3, under a different path prefix, so your app talks to Lockwell natively. No SigV4 signing, no XML. JSON in, JSON out. Auth is a short-lived native bearer token (`lwtk_...`) minted from an existing access key. The client **auto-manages** it for you: * mints on first use (`POST /api/v1/auth/token`), * caches until shortly before expiry, * refreshes transparently, and * re-mints once on a `401`. Token acquisition is concurrency-safe (single-flight), so a burst of requests never mints per request. The Go and Java native clients manage the token the same way. See the [Go](/sdks/go) and [Java](/sdks/java) SDK pages. Two native-surface capabilities: * **Native signed URLs for GET and PUT.** These use native HMAC tokens instead of the S3 client's query-SigV4 GET/PUT/HEAD/DELETE URLs. See [Signed URLs](/guide/signed-urls). * **Edge-safe in Node.** The native client imports nothing from `node:*`. It runs unchanged on Cloudflare Workers, Vercel Edge, Bun, and Deno. See [Edge runtimes](/guide/edge-runtimes). ```ts import { NativeClient } from "@kelphect/sdk"; const native = new NativeClient({ endpoint: "http://localhost:9000", // public listener (same host:port as S3) accessKeyId: process.env.LOCKWELL_ACCESS_KEY_ID, secretKey: process.env.LOCKWELL_SECRET_KEY, }); await native.putObject("my-bucket", "report.txt", "hello", { contentType: "text/plain" }); const up = await native.signUrl({ method: "PUT", bucket: "my-bucket", key: "photo.jpg", ttlSeconds: 300 }); ``` See [Data operations](/guide/data-operations) for the full object API and the [Native API reference](/reference/native-api) for the route set. ### Native Wire is an explicit transport choice LNW/1 carries the same authorized native operations in deterministic binary frames over a separate configured TLS listener (commonly `127.0.0.1:9444` or `:9444`). It is experimental, opt-in, and disabled by default. The server requires TLS 1.3 and hostname verification off loopback; optional/required mTLS is listener policy. The binary client negotiates capabilities, flow-control windows, stream limits, upload deadlines, and a fresh access-key proof. It does not use the `/api/v1/` HTTP path and it never falls back to JSON, XML, or S3. Use the [LNW architecture guide](/guide/native-wire) and [wire reference](/reference/native-wire) for the frame layout, auth/replay retry contract, error codes, Object Lock/metadata boundary, and rollback. The merged server consumers are [`@kelphect/sdk-native`](/sdks/bun-native), [SolidStart v2](/sdks/solidstart), and the \[Spring Boot starter] (/sdks/java-spring-wire). Existing Go/Node/Java core pages continue to describe S3 and HTTP-native JSON until their standalone LNW transports land and pass their source-owner gates. ## JSON Admin API (`/admin/api/v1/`) The control plane: tenants, service accounts, access keys, quotas, and audit, as versioned JSON on a separate admin listener (never the public S3 port). It authenticates with an **admin API token** (`lwadm_...`, minted offline with `lockwell admin-token create`) sent as `Authorization: Bearer `, not SigV4. Authorization composes an **RBAC role** (`owner` / `operator` / `viewer`) with an optional single-tenant scope, so a tenant-scoped token cannot cross to another tenant. Every request, success and denial, is audited. Mutations accept a `dryRun` flag to preview the plan without applying it. ```ts import { AdminClient } from "@kelphect/sdk"; const admin = new AdminClient({ endpoint: "http://localhost:9001", // admin listener, not the S3 port token: process.env.LOCKWELL_ADMIN_TOKEN, // Authorization: Bearer lwadm_... }); await admin.createTenant({ id: "acme", name: "Acme Inc" }); const key = await admin.createKey("acme", { scopes: "read,write,delete" }); console.log(key.secretKey); // shown exactly once. Store it now. ``` See the [Admin API reference](/reference/admin-api) and [Tenancy and auth](/guide/tenancy-and-auth). ## One pipeline, no security bypass The three surfaces are different transports over one set of in-process domain services. The native API and the Admin API do not reach around the S3 path's guarantees. They call the same tenant lifecycle, metadata repo, scope/policy engine, object coordinator, secret cipher, and auditor: * **Tenant isolation** comes from the credential, never the request path. A native token carries its tenant, and another tenant's bucket simply does not exist for it. * **Scope enforcement** (`read`/`write`/`delete`/`admin` plus bucket/prefix scopes) is identical across surfaces. A read-only key cannot write on any of them. * **Encryption, dedup, quota, object-lock, retention** apply identically. An object written via the native API is encrypted-at-rest and quota-checked exactly like an S3 write. ## Which client, per language | Surface | Node | Go | Java | | ------------------------ | -------------- | ----------------------- | ---------------------- | | S3 data plane | `Client` | `lockwellsdk.Client` | `LockwellClient` | | Native binary data plane | `NativeClient` | `lockwellwire.Client` | `LockwellNativeWireClient` | | Legacy native JSON | `LegacyNativeClient` | `lockwellnative.Client` | `LockwellNativeClient` | | Admin API | `AdminClient` | `lockwelladmin.Client` | `LockwellAdminClient` | | App kit (native + admin) | `LockwellKit` | `lockwellkit.Kit` | `LockwellKit` | The **app kit** composes the native and admin clients into a near-zero-glue multi-tenant app surface: tenant-to-key provisioning, per-tenant clients, browser-direct signed URLs, and webhook verification. It is the recommended starting point for a new multi-tenant app. See [App kit](/guide/app-kit). ## Deliberate non-goals Across every surface, by design: no public or anonymous access without a token or signed URL, no SSE-KMS, no IAM/STS/bucket-policy management API, and webhook-only event notifications (SNS/SQS/Lambda targets return `501`). The S3 wire API does support SSE-C with a customer-provided AES-256 key on every applicable request; the native and admin surfaces, including LNW/1, do not. The first-party Go, Node, and Java S3 clients expose typed SSE-C helpers and query-SigV4 GET/PUT/HEAD/DELETE presigners. These boundaries keep unsupported behavior explicit and fail-closed. See [Tenancy and auth](/guide/tenancy-and-auth). --- --- url: /guide/native-wire.md description: >- Lockwell Native Wire (LNW/1) architecture, security, setup, limits, rollout, and failure semantics. --- # Native Wire (LNW/1) Lockwell Native Wire (LNW/1) is the deterministic binary transport for the native data plane. It is **experimental, opt-in, and disabled by default** (`defaultEnabled: false`). The existing S3-compatible and HTTP-native JSON clients remain supported choices while an LNW deployment completes its own qualification. LNW is a transport, not a fourth authorization or storage surface: requests still enter the shared tenant, policy, quota, retention, object, metadata, encryption, audit, and storage pipeline. The normative protocol and registry are maintained in [`docs/native-wire-v1.md`](https://github.com/RusticStack/lockwell/blob/main/docs/native-wire-v1.md), [`native-wire-v1.json`](/native-wire-v1.json), and the [wire reference](/reference/native-wire). The website pages are generated as pre-rendered HTML and raw Markdown (`/guide/native-wire.md`) so the contract can be fetched without JavaScript. Shared cross-language snippets and their security notes live in [`/sdk-public-api-examples-v1.json`](/sdk-public-api-examples-v1.json). ## When to use it Use LNW when a server-side application needs multiplexed, backpressured binary object I/O over a direct TLS connection and can operate a qualified Node, Bun, or JVM runtime. Use the [S3 surface](/guide/the-three-surfaces#s3-data-plane-sigv4) for existing S3 tools or genuine SSE-C. Use the [HTTP-native JSON surface](/guide/the-three-surfaces#native-json-data-plane-api-v1) when a deployment has not enabled the binary listener. Use the [Admin API](/reference/admin-api) for provisioning, quotas, and audit rather than an access-key data-plane connection. LNW does not silently fall back to HTTP, JSON, XML, S3, or an object-store emulator. A refused capability, unsupported runtime, failed handshake, or transport error is returned to the caller with a stable error; choose another surface explicitly if that is the desired migration behavior. ## Architecture and wire shape The listener owns framing and connection state only. It dispatches operation codes to the existing native handlers; it never calls the storage backend directly. This keeps authorization and tenant isolation in one place: ```mermaid flowchart LR C["Qualified client"] -->|TLS 1.3| L["LNW listener"] L -->|typed operation and FieldDocument| N["Native domain handlers"] N --> P["Policy, scope, quota, retention, audit"] P --> O["Object coordinator"] O --> S[("Tenant-qualified encrypted store")] ``` Every frame has a 40-byte big-endian header (`LKW1`, version, type, flags, direction sequence, request/stream IDs, code, reserved bytes, metadata length, and payload length), followed by TLV metadata, payload, and a CRC32C. The CRC detects corruption and framing mistakes; TLS supplies confidentiality and channel integrity. Metadata TLVs are sorted, bounded, shortest-form UTF-8, NUL-free, and deterministic. Unknown optional fields are skipped; unknown critical fields, duplicate singular fields, non-canonical order, and invalid UTF-8 fail closed. Nested values use deterministic `FieldDocument`; JSON is never a wire fallback. The default negotiated limits are 64 KiB metadata, 1 MiB payload per DATA frame, 1,114,156 bytes per frame, 128 streams per connection, 1,024 connections, a 4 MiB stream window, and a 16 MiB connection window. Clients must apply the negotiated minimum before allocating and must respect `WINDOW_UPDATE` credit. Only one reader and one writer goroutine own a connection; independent streams may execute concurrently. ## TLS, mTLS, and authentication Non-loopback LNW listeners require TLS 1.3. Hostname verification remains enabled. Configure a private CA on the client when the certificate is not publicly trusted. Listener client authentication is `none`, `optional-mtls`, or `required-mtls`; mTLS is a listener policy, not a capability negotiated in HELLO/WELCOME. The native clients require a certificate and key together when mTLS is used. Plaintext is reserved for an explicit loopback test configuration. The connection state machine is: 1. `HELLO` advertises the protocol range, capabilities, and receive limits. 2. `WELCOME` selects exactly one version and the capability intersection. There is no silent downgrade. 3. `AUTH` proves the access key with a timestamp, fresh 16-byte nonce, and HMAC-SHA-256 over the transcript. 4. `AUTH_OK` returns the tenant, session expiry, negotiated capabilities, and effective limits; `AUTH_ERROR` is bounded. 5. Odd client stream IDs carry one operation each (`REQUEST`, optional `DATA`, `END`); responses use `RESPONSE`, `DATA`, `END`, or a typed terminal `ERROR`. Credential providers are evaluated for each new connection, so rotation and revocation do not require rebuilding a client. The server rechecks credential expiry, revocation, tenant-disabled state, and scope before every new stream. Authentication state is bounded per access-key principal: up to 256 unexpired nonces for each of 1,024 principals by default. A duplicate nonce is `AUTH_REPLAY` (0x0103), non-retryable and without a hint. Per-principal capacity is `RATE_LIMITED` (0x0300), retryable with an optional `retryAfterMillis` no greater than 600,000; the client discards the connection, resolves credentials again, and opens a fresh TLS connection with a fresh proof. A direct-peer accept limiter, when explicitly enabled, can close a socket before TLS and therefore emits no `AUTH_ERROR`. ## Streaming, cancellation, and failure semantics `PUT_OBJECT` and `UPLOAD_PART` stream bytes under both flow-control windows and the process-wide `security.max_concurrent_uploads` limit (default 128). `security.upload_idle_timeout` defaults to two minutes and `security.upload_max_duration` to 24 hours per stream. Only successful body reads reset the idle timer; unrelated frames, PINGs, or activity on another stream cannot extend it. Capacity errors are retryable. A deadline returns `DEADLINE_EXCEEDED` and removes uncommitted object or part state. A client `CANCEL` stops work and returns `CANCELLED` if no terminal frame has already been sent. A caller deadline may shorten, never extend, server limits. Application retries are bounded and operation-aware. Reads and idempotent deletes may be retried. Buffered writes need an idempotency key and a replayable body; one-shot streaming bodies are not replayed implicitly. A fresh connection is required after authentication-capacity retry. There is no transparent byte-offset PUT/GET resume; after a conclusive failure, callers use the committed multipart parts or an explicit idempotent operation. Errors contain only a stable code, retryable bit, optional bounded retry hint, safe message, and request/audit correlation. They never contain credentials, tokens, tenant secrets, payloads, filesystem paths, stacks, or peer certificates. See [errors and retries](/guide/errors-and-retries) for the application-facing policy. ## Capabilities and data behavior The effective Lockwell server mask advertises buckets, objects, pagination, multipart, versioning, Object Lock, tags, signed capabilities, trace context, CORS, and webhook notifications. `SSE_C` is intentionally **not** advertised because the native domain path has no server-enforced SSE-C operation. Use S3 for customer-provided AES-256 keys. The reserved Admin capability bit and `0x1000`–`0x10ff` operation range are unimplemented and require a distinct authenticated contract. The shipping LNW clients preserve ordered duplicate user metadata as a user-owned namespace; typed internal SSE-C and Object Lock state is separate and cannot be synthesized by metadata names. Object Lock retention and legal hold are authorized and committed atomically with a new version. Governance bypass is not supported. Checksums, ranges, tags, version/delete-marker listing, multipart completion/abort/resume discovery, batch delete, CORS, webhook configuration, and signed GET/PUT capabilities follow the operation registry and negotiated capability bits. Webhook configuration returns a signing secret once; use a credential-free HTTPS target and HMAC headers, never a token in the URL query. ## Enable a listener Start with a loopback certificate or a private CA in a non-production environment. The server-side configuration is under `[native_wire]` in [`examples/lockwell.toml`](https://github.com/RusticStack/lockwell/blob/main/examples/lockwell.toml): ```toml [native_wire] enabled = false # opt in explicitly listen_addr = "127.0.0.1:9444" tls_cert_file = "./certs/lockwell.crt" tls_key_file = "./certs/lockwell.key" tls_ca_file = "./certs/lockwell-ca.pem" # required for mTLS client_auth = "none" # none | optional-mtls | required-mtls max_connections = 1024 max_streams_per_connection = 128 handshake_timeout = "10s" idle_timeout = "2m" ``` For a public bind, use a certificate whose name matches the endpoint and set the client CA policy deliberately. Keep `enabled = false` while rolling back: drain the listener, wait for admitted streams to finish, and remove the native-wire client selection. S3 and HTTP-native JSON remain unchanged and no metadata migration is performed. ## Qualified clients and framework boundaries The repository currently contains these implementation-backed LNW consumers: | Consumer | Tested runtime floor | Entry point | Boundary | | --- | --- | --- | --- | | `@kelphect/sdk-native` 0.1.0 | Node 22+, Bun 1.4+ | `/node`, `/bun`, or root condition | Server-only raw TLS; browser/default imports throw | | `@kelphect/sdk-solidstart` 0.1.0 | built server Node 22+, Bun 1.4+ | `/server`, `/node`, `/bun` | SolidStart v2; Nitro `node_server`, `node_cluster`, `bun`; edge/static refused | | `@kelphect/sdk-nextjs` 0.1.0 | Next.js 16.3.3–16.x; Node 22+, Bun 1.4+ | package root (`react-server`/`node`) | App Router server-only; Client/Edge/middleware refused | | `com.lockwell:lockwell-spring-boot-starter` 0.2.2 | JDK 25, Spring Boot 4.1.1 | `com.lockwell.sdk.springwire.*` | Opt-in starter; separate `--release 25` artifact | These are source-shipped and test-qualified contracts, not a promise that a package has been published to your registry. The existing Go, Node, and Java core SDK pages document S3 and HTTP-native JSON. Go's standalone LNW client, Node's primary-LNW transport, Java shared-core LNW client, and Nuxt remain pending their open source-owner PRs; see the [capability index](/reference/sdk-capabilities) for the exact boundary. Do not infer support for .NET, Rust, PHP, or Ruby from this page until a reviewed, merged, tested source contract exists. ## Rollout checklist Before enabling LNW for a tenant, reproduce the byte fixtures and malformed-frame tests, exercise auth/replay/downgrade/ scope/Object Lock denials, verify stream cancellation and upload bounds, run the language consumer tests, and record the exact server/client commit and TLS policy. Keep a tested S3 or HTTP-native mode as an explicitly selected rollback option, never as an exception handler that silently changes protocol. The [wire reference](/reference/native-wire), [Bun/Node guide](/sdks/bun-native), [SolidStart guide](/sdks/solidstart), [Next.js guide](/sdks/nextjs), and [Spring guide](/sdks/java-spring-wire) link directly to runnable examples and source tests. --- --- url: /guide/migration.md description: >- Migrate an S3 application to Lockwell without overstating compatibility, and understand native SDK language and feature boundaries. --- # Migration and compatibility Start with the S3-compatible surface when replacing an endpoint in an existing application. Move selected workflows to the native client only when JSON errors, constrained signed URLs, browser CORS, notifications, or the app kit materially simplify the application. ## S3 migration checklist 1. Pin a Lockwell server release and the matching first-party SDK release line. 2. Use path-style addressing first; enable virtual-hosted style only after wildcard DNS/TLS validation. 3. Set the region explicitly when it differs from `us-east-1`. 4. Run the application's real object names, ranges, checksums, multipart sizes, versioning, and Object Lock denial paths. 5. Replace provider-specific IAM/STS/KMS/event-bus features instead of assuming S3 wire compatibility includes them. 6. Verify backup/restore and migration-copy evidence before making a provider-replacement claim. Lockwell supports the operations in the [S3 operations reference](/reference/s3-operations). Unsupported subresources fail closed. The compatibility matrix is evidence, not permission to infer an unlisted feature. ## Native adoption Native clients use `/api/v1/` on the public object listener and auto-manage short-lived bearer tokens minted from an access key. Admin clients use `/admin/api/v1/` on the private admin listener and an admin token. Do not swap their credentials or expose the admin listener as a public object endpoint. The app kit composes existing native/admin calls; it does not introduce a fourth wire API. Keep application-owned tenant and ERP mappings in the application database, and use opaque identifiers rather than customer names or tax ids. ### Selecting Native Wire during migration LNW/1 is an explicit, experimental binary transport for the native data plane. It runs on a separate configured TLS 1.3 listener and has no JSON/S3 fallback. Migrate a server-side workflow only after the target runtime appears in the [Native Wire capability index](/reference/sdk-capabilities): the currently merged consumers are the Node/Bun shared TypeScript client, SolidStart v2 server adapter, and Spring Boot starter. Keep S3 or HTTP-native JSON as an explicitly selected rollback mode, and do not catch a native error to silently change transports. There is no transparent byte-offset resume; use idempotency keys or committed multipart parts. ## Supported native SDK languages The currently supported first-party SDK languages are Go, Node/TypeScript, and Java. There is no first-party .NET, Rust, PHP, or Ruby SDK; those languages are explicit product non-goals. Applications in those ecosystems may use a compatible S3 library against the documented S3 surface, but that does not create a native/admin SDK support claim. Proposal documents, generated OpenAPI experiments, and unexported server handlers are not public SDK APIs. A capability is documented here only when exported source and executable tests support it. ## Security differences to preserve * Buckets remain private; there is no anonymous/public-bucket migration mode. * S3 presigned GET/PUT/HEAD/DELETE and native signed GET/PUT URLs are bearer capabilities. Keep TTLs short. * SSE-C is supported on the S3 surface with typed helpers; SSE-KMS is not. * Retention and legal-hold denials are expected safety behavior, not compatibility bugs. * Never log access-key secrets, admin/bearer tokens, SSE-C keys, raw object data, or signed URL query strings. ## Release and package boundary Examples use the repository's historical `0.2.2` coordinates so source consumers can compile against this branch. They do not claim a currently approved commercial package release. Follow the release ledger and written license grant before commercial deployment; do not infer package publication from documentation alone. --- --- url: /guide/when-not-to-use.md description: >- The disqualification checklist. What Lockwell refuses to do, what it does instead, and which tool to reach for when a refusal rules it out. If a row on this page matches your workload, do not use Lockwell. --- # When not to use Lockwell Most storage products tell you what they can do. This page is the other half, kept deliberately on the front of the site: what Lockwell **refuses** to do, and what to use when a refusal rules it out. Every entry here is enforced by the server (refused operations fail closed with an error; nothing is silently dropped or half-implemented) and pinned by tests in the repository, so this list cannot quietly drift out of date. ## Do not use Lockwell if you need… ### Public buckets or anonymous access Lockwell is private-only. There are no public buckets, no anonymous reads, no public website hosting, and no unauthenticated presigned POST uploads. Every request is authenticated against a tenant-scoped key or a signed URL minted from one. Note what this does NOT rule out: sharing. An expiring signed link (a 7-day client download, a one-hour browser upload) carries its own permission, so the recipient needs no account and no key, and the link dies on schedule. What is refused is the permanent, unauthenticated, world-readable path. If your workload truly is "host these images for the open internet", put a CDN in front of a public-object store instead: Cloudflare R2, Backblaze B2, or S3 with CloudFront. Lockwell can still hold the private originals behind it. ### A multi-node, replicated cluster The embedded metadata engine is **single-node by design** in v1. There is no multi-node replication, no erasure coding, and no multi-AZ failover; the replicated deployment profile is suspended and its release gate fails closed. Durability comes from per-commit or grouped fsync, always-on at-rest encryption, scrub/repair, and backup/restore drills. The crash-takeover and disaster drills that prove them are part of the release gates. Status, honestly: replication is deferred, not denied. It is in design, and it ships only when it passes the same takeover and durability drills everything else passed; until that day the server says no. If you need a cluster that survives the loss of a whole machine without a restore today, use a replicated system: S3, R2, B2, or self-hosted Ceph RGW or Garage on multiple nodes. Teams that want Lockwell anyway run a warm standby meanwhile: a second instance fed by scheduled S3-level sync, with restore drills proving the seam. ### AWS IAM, STS, or KMS Lockwell does not implement IAM policies, STS temporary credentials, or SSE-KMS. Access control is tenant-scoped access keys with read/write/delete/admin flags and optional bucket scoping; encryption is always-on at rest with per-tenant data keys. A request for SSE-KMS is **refused rather than faked**: Lockwell will not accept a KMS header, store the object under its own keys, and let your compliance audit believe a KMS was involved. SSE-C (your key, per request) is supported. ### Provider-native event buses and analytics Bucket notifications deliver to **webhooks** with constant-time HMAC signatures. There is no delivery to SNS, SQS, or Lambda. S3 Select, S3 Tables, S3 Vectors, Object Lambda, and Express directory buckets are permanent non-goals. If your pipeline is built on those, stay on AWS for those buckets. ### Storage tiering or remote backends Objects live on the local filesystem of the node, encrypted. There is no tiering to cold storage and no S3-as-backend proxy mode. There is also no shipping Xet storage engine, Xet protocol compatibility, xorbs, shards, caches, or global deduplication. A detached content-defined-chunking evaluation matched the published Xet gear table and fixture, but its attempted unpacked writer was rejected after review found descriptor use could grow with chunk count. It has no public configuration, production write path, SDK behavior, recovery evidence, or demonstrated performance/storage benefit. Datasets that cannot fit one machine's disks are out of scope (see the cluster row above). ## What "fail closed" means here An S3 call outside the documented surface returns an explicit error instead of pretending to succeed. The [parity ledger](https://github.com/RusticStack/lockwell/blob/main/docs/s3-api-parity-ledger.md) documents every operation and its exact behavior; the [compatibility contract](https://github.com/RusticStack/lockwell/blob/main/docs/final-replacement-contract.md) names the workloads where Lockwell is a candidate replacement, and the workloads where it is not. Both are enforced by sync tests, so the docs and the server cannot disagree for long. ## When Lockwell is the right choice The honest inverse, briefly: private S3 object storage for authenticated SDK/CLI clients; encrypted tenant-scoped backup or artifact storage; compliance-oriented internal S3 where audit, retention, legal hold, and operator recovery matter more than broad AWS feature parity; and the whole storage layer of a multi-tenant app via the [app kit](/guide/app-kit): provisioning, scoped keys, signed browser uploads, verified webhooks, one SDK. If you are migrating from MinIO or Garage, run `lockwell migration s3 plan` first: it refuses blocked source features instead of silently dropping them, and only reviewed plans execute. Deeper: [Getting started](/guide/getting-started) · [The three surfaces](/guide/the-three-surfaces) · [Tenancy & auth](/guide/tenancy-and-auth). --- --- url: /guide/data-operations.md description: >- Write and read object bytes with the Lockwell native and S3 clients, including buffered and streaming PutObject, GetObject, ranges, versions, and HeadObject. --- # Upload & download This page covers writing and reading object bytes. It walks `PutObject` (buffered and streaming), `GetObject` (whole-object and streaming, ranges, versions, response overrides), and `HeadObject`. The examples for the legacy HTTP/JSON surface lead with the native client and show the S3 client right after, behind the surface toggle. New Go applications should use the direct binary `pkg/lockwellwire` client described in the [Go SDK guide](/sdks/go#pkg-lockwellwire-the-lnw1-native-wire-client). Two clients write to the same encrypted, per-tenant store: * the **legacy native client** uses an auto-managed JSON bearer token (`NativeClient` / `lockwellnative` / `LockwellNativeClient`); * the **S3 client** signs every request with SigV4 (`Client` / `lockwellsdk` / `LockwellClient`). For the operation matrix at a glance, see the [native data-plane reference](/reference/native-api) and the [S3 operations reference](/reference/s3-operations). Copy, list, delete, and conditional writes have their own pages, linked at the bottom. ## Constructing a client The native client takes an endpoint plus an access-key id and secret, then mints and refreshes its own bearer token (single-flight, thread-safe). The S3 client takes the same credentials and signs each request directly. ::: code-group ```ts [Node] import { NativeClient, Client } from "@kelphect/sdk"; // Native JSON data plane (token managed for you): const native = new NativeClient({ endpoint: "https://objects.example.com", // public listener; /api/v1 is added for you accessKeyId: process.env.LOCKWELL_ACCESS_KEY_ID, secretKey: process.env.LOCKWELL_SECRET_KEY, }); // S3 data plane (SigV4): const s3 = new Client({ endpoint: "https://objects.example.com", accessKeyId: process.env.LOCKWELL_ACCESS_KEY_ID, secretKey: process.env.LOCKWELL_SECRET_KEY, }); ``` ```go [Go] import ( "github.com/KelpHect/lockwell/pkg/lockwellnative" "github.com/KelpHect/lockwell/pkg/lockwellsdk" ) native, err := lockwellnative.New( "https://objects.example.com", os.Getenv("LOCKWELL_ACCESS_KEY_ID"), os.Getenv("LOCKWELL_SECRET_KEY"), ) s3, err := lockwellsdk.New( "https://objects.example.com", lockwellsdk.Credentials{ AccessKeyID: os.Getenv("LOCKWELL_ACCESS_KEY_ID"), SecretKey: os.Getenv("LOCKWELL_SECRET_KEY"), }, ) ``` ```java [Java] import com.lockwell.sdk.nativeapi.LockwellNativeClient; import com.lockwell.sdk.LockwellClient; import com.lockwell.sdk.Credentials; var nativeClient = LockwellNativeClient.builder() .endpoint("https://objects.example.com") .accessKeyId(System.getenv("LOCKWELL_ACCESS_KEY_ID")) .secretKey(System.getenv("LOCKWELL_SECRET_KEY")) .build(); var s3 = LockwellClient.builder() .endpoint("https://objects.example.com") .credentials(new Credentials(System.getenv("LOCKWELL_ACCESS_KEY_ID"), System.getenv("LOCKWELL_SECRET_KEY"))) .build(); ``` ::: The rest of this page uses `native` for the native client and `s3` for the S3 client. ## Put an object (buffered) Hand the SDK a byte buffer and it does one request. The native client returns the stored object's ETag and, on a versioned bucket, a version id. :::: native ::: code-group ```ts [Node] const put = await native.putObject("reports", "q1/summary.txt", "hello world", { contentType: "text/plain", metadata: { author: "finance", quarter: "Q1" }, }); console.log(put.etag, put.versionId); ``` ```go [Go] put, err := native.PutObject(ctx, lockwellnative.PutObjectInput{ Bucket: "reports", Key: "q1/summary.txt", Body: strings.NewReader("hello world"), ContentType: "text/plain", Metadata: map[string]string{"author": "finance", "quarter": "Q1"}, }) fmt.Println(put.ETag, put.VersionID) ``` ```java [Java] import com.lockwell.sdk.nativeapi.NativeTypes.PutOptions; var put = nativeClient.putObject("reports", "q1/summary.txt", "hello world".getBytes(), new PutOptions() .contentType("text/plain") .metadata("author", "finance") .metadata("quarter", "Q1")); System.out.println(put.etag() + " " + put.versionId()); ``` ::: :::: :::: s3 The same with the S3 client: ::: code-group ```ts [Node] const put = await s3.putObject("reports", "q1/summary.txt", Buffer.from("hello world"), { contentType: "text/plain", metadata: { author: "finance", quarter: "Q1" }, }); console.log(put.etag, put.versionId); ``` ```go [Go] put, err := s3.PutObject(ctx, "reports", "q1/summary.txt", []byte("hello world"), lockwellsdk.WithContentType("text/plain"), lockwellsdk.WithMetadata(map[string]string{"author": "finance", "quarter": "Q1"}), ) fmt.Println(put.ETag, put.VersionID) ``` ```java [Java] var put = s3.putObject("reports", "q1/summary.txt", "hello world".getBytes(), new LockwellClient.PutOptions() .contentType("text/plain") .metadata("author", "finance") .metadata("quarter", "Q1")); System.out.println(put.etag() + " " + put.versionId()); ``` ::: :::: User metadata is stored alongside the object and returned on every read. On the native path it travels as `X-Lockwell-Meta-*` headers; on the S3 path as `x-amz-meta-*`. Both SDKs surface it as a plain key-value map (the prefix is stripped for you). ### PutObject options | Option | Native (Go / Node / Java) | S3 (Go / Node / Java) | Effect | | -------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | Content type | `ContentType` / `contentType` / `.contentType` | `WithContentType` / `contentType` / `.contentType` | Sets the stored media type. | | User metadata | `Metadata` / `metadata` / `.metadata` | `WithMetadata` / `metadata` / `.metadata` | Arbitrary key-value pairs. | | Idempotency key | `IdempotencyKey` / `idempotencyKey` / `.idempotencyKey` | `WithIdempotencyKey` / `idempotencyKey` / `.idempotencyKey` | Makes a retried write replay the stored result. See [conditional writes](/guide/conditional-writes). | | Checksum | `Checksums` / `checksums` / `.checksum` | `WithChecksumAlgorithm` / `checksumAlgorithm` / `.checksum` | Server verifies and persists an end-to-end digest. See [checksums](/guide/checksums). | | Create-only | `IfNoneMatch:"*"` / `ifNoneMatch:'*'` / `.ifAbsent()` | `WithPutIfNoneMatch("*")` / `ifNoneMatch: "*"` / `.ifNoneMatch("*")` | Write only when the key is absent. See [conditional writes](/guide/conditional-writes). | | Overwrite-only | `IfMatch` / `ifMatch` / `.ifMatch` | (use native or copy) | Write only when the current ETag matches. See [conditional writes](/guide/conditional-writes). | | SSE-S3 | (always on at rest) | `WithServerSideEncryption` / `serverSideEncryption` / `.serverSideEncryption` | Requests server-managed encryption at rest. | | Object Lock at write | set with `setObjectRetention` after write | `WithObjectLock*` headers via `PutOptions` | Apply retention or a legal hold. See [object lock](/guide/object-lock). | Conditional create and overwrite are native-client features. The native `putObject` takes `If-None-Match` / `If-Match` directly. The S3 `PutObject` does not, so for an existing object reach for a conditional [copy](/guide/copying-objects). Lockwell issues presigned GET/PUT/HEAD/DELETE URLs on the S3 client; for a browser-direct upload you may also use a native [signed URL](/guide/signed-urls). ::: info The S3 `PutObject` supports create-only `If-None-Match: *`. Use native `putObject` for overwrite-only `If-Match`, or a conditional copy for an existing key. ::: ### Encryption status and SSE-S3 The native client's encryption posture is a server deployment property. Production configs enable encryption, so native writes are stored as encrypted chunks under per-tenant data keys and the native client has no SSE option. Native GET and HEAD responses expose the `X-Lockwell-Encrypted` header; Java surfaces it as `GetResult.encrypted()` and `HeadResult.encrypted()`. Do not run tenant-handling workloads on an encryption-disabled deployment. On the S3 client, `WithServerSideEncryption` / `serverSideEncryption: true` / `.serverSideEncryption()` asks for SSE-S3: a server-managed, per-tenant key encrypts the object at rest. The response echoes `serverSideEncryption: "AES256"`. SSE-KMS is a non-goal. The S3 wire and all three first-party S3 clients expose typed SSE-C helpers; supply the same raw 32-byte customer key on every applicable read, write, copy, multipart-part, and completion request, and never log or persist it in application telemetry. User metadata is a lossless, duplicate-preserving user namespace. The merged metadata contract stores it separately from internal SSE-C and Object Lock state, including for historical names such as `x-amz-meta-lockwell-sse-customer-key-md5`. Caller metadata cannot manufacture an internal encryption marker. This is a metadata-boundary guarantee, not permission to use the native or LNW/1 surfaces for SSE-C; genuine customer-provided keys remain an S3-only capability. :::: s3 ::: code-group ```ts [Node] const put = await s3.putObject("vault", "ledger.json", Buffer.from(data), { serverSideEncryption: true, }); console.log(put.serverSideEncryption); // "AES256" ``` ```go [Go] put, err := s3.PutObject(ctx, "vault", "ledger.json", data, lockwellsdk.WithServerSideEncryption()) fmt.Println(put.ServerSideEncryption) // "AES256" ``` ```java [Java] var put = s3.putObject("vault", "ledger.json", data, new LockwellClient.PutOptions().serverSideEncryption()); System.out.println(put.serverSideEncryption()); // "AES256" ``` ::: :::: ## Put an object (streaming) Hand the SDK a reader and the body goes to the server without ever sitting whole in memory. Reach for this when a file is larger than you want to buffer. The native client streams the raw bytes. A streaming body cannot be replayed, so the client mints a fresh token before it starts streaming and a token-expiry 401 retry is never needed. A 401 from a genuinely revoked key still surfaces. :::: native ::: code-group ```ts [Node] import { createReadStream } from "node:fs"; import { Readable } from "node:stream"; // A ReadableStream / async-iterable body streams without buffering: const file = Readable.toWeb(createReadStream("./big.bin")); await native.putObject("reports", "big.bin", file, { contentType: "application/octet-stream", contentLength: 1_048_576, // set so the server enforces the size cap + quota up front }); ``` ```go [Go] f, _ := os.Open("./big.bin") defer f.Close() info, _ := f.Stat() _, err := native.PutObject(ctx, lockwellnative.PutObjectInput{ Bucket: "reports", Key: "big.bin", Body: f, ContentType: "application/octet-stream", ContentLength: info.Size(), // lets the server enforce the size cap + quota up front }) ``` ```java [Java] import java.io.FileInputStream; // An InputStream supplier streams the body: nativeClient.putObject("reports", "big.bin", () -> { try { return new FileInputStream("./big.bin"); } catch (Exception e) { throw new RuntimeException(e); } }, new PutOptions().contentType("application/octet-stream")); ``` ::: :::: Set `contentLength` (native) where you know the size up front: it lets the server enforce the per-object size cap and the tenant quota before it accepts a single byte, and it avoids chunked transfer encoding. For very large or resumable uploads, use [multipart upload](/guide/multipart-uploads) instead. :::: s3 The S3 client wraps the stream as an `aws-chunked` body with an end-to-end checksum trailer, so a checksum algorithm is required on `putObjectStream`. An idempotency key is not supported for the S3 stream (the trailer is not known at reservation time); use the buffered `PutObject` when you need idempotency. ::: code-group ```ts [Node] import { createReadStream } from "node:fs"; // Checksum required; sent in an aws-chunked trailer: await s3.putObjectStream("reports", "big.bin", createReadStream("./big.bin"), "CRC64NVME", { contentType: "application/octet-stream", }); ``` ```go [Go] g, _ := os.Open("./big.bin") defer g.Close() info, _ := g.Stat() _, err := s3.PutObjectStream(ctx, "reports", "big.bin", g, info.Size(), lockwellsdk.ChecksumCRC64NVME, lockwellsdk.WithContentType("application/octet-stream"), ) ``` ```java [Java] import java.nio.file.Files; import java.nio.file.Path; try (var in = Files.newInputStream(Path.of("big.bin"))) { s3.putObjectStream("reports", "big.bin", in, "CRC64NVME", new LockwellClient.PutOptions().contentType("application/octet-stream")); } ``` ::: :::: ## Get an object `GetObject` streams the body. On both clients you own the body and must close it, and the result carries the object's metadata: content type, length, ETag, version id, and any checksums. :::: native ::: code-group ```ts [Node] // Streaming download (no whole-object buffering): const obj = await native.getObjectStream("reports", "big.bin"); console.log(obj.contentType, obj.contentLength, obj.etag); for await (const chunk of obj.body) { /* ...process chunk... */ } // Buffered convenience (small objects): const small = await native.getObject("reports", "q1/summary.txt"); console.log(small.body.toString("utf8"), small.metadata); ``` ```go [Go] // Streaming; close the reader: obj, err := native.GetObject(ctx, lockwellnative.GetObjectInput{Bucket: "reports", Key: "big.bin"}) if err != nil { return err } defer obj.Close() fmt.Println(obj.ContentType, obj.ContentLength, obj.ETag) io.Copy(dst, obj) ``` ```java [Java] import com.lockwell.sdk.nativeapi.NativeTypes.GetResult; // Streaming InputStream; try-with-resources closes it: try (GetResult obj = nativeClient.getObject("reports", "big.bin")) { System.out.println(obj.contentType() + " " + obj.contentLength()); obj.body().transferTo(out); } ``` ::: :::: :::: s3 The same with the S3 client: ::: code-group ```ts [Node] const s3obj = await s3.getObjectStream("reports", "big.bin"); const all = await s3obj.readAll(); // or iterate s3obj.body ``` ```go [Go] out, err := s3.GetObject(ctx, "reports", "big.bin") if err != nil { if lockwellsdk.IsNotFound(err) { /* ... */ } return err } defer out.Body.Close() io.Copy(dst, out.Body) fmt.Println(out.ContentType, out.ContentLength, out.Metadata) ``` ```java [Java] import java.util.Map; // Streaming: try (var got = s3.getObjectStream("reports", "big.bin", Map.of())) { got.body().transferTo(out); } // Buffered (small objects): var small = s3.getObject("reports", "q1/summary.txt", Map.of()); System.out.println(new String(small.body()) + " " + small.metadata()); ``` ::: :::: ### Byte ranges Pass a range to fetch part of an object. The server replies `206 Partial Content` with a `Content-Range`. The native client takes the raw HTTP `Range` value; the S3 client takes a typed range (start, end inclusive, `end < 0` means "to end"). :::: native ::: code-group ```ts [Node] const part = await native.getObjectStream("reports", "big.bin", { range: "bytes=0-1023" }); console.log(part.contentRange); ``` ```go [Go] part, _ := native.GetObject(ctx, lockwellnative.GetObjectInput{ Bucket: "reports", Key: "big.bin", Range: "bytes=0-1023", }) defer part.Close() fmt.Println(part.ContentRange) ``` ```java [Java] // range + optional version: try (var part = nativeClient.getObject("reports", "big.bin", "bytes=0-1023", null)) { System.out.println(part.contentRange()); } ``` ::: :::: :::: s3 ::: code-group ```ts [Node] const s3part = await s3.getObjectStream("reports", "big.bin", { range: "bytes=0-1023" }); ``` ```go [Go] // Typed range, inclusive: out, _ := s3.GetObject(ctx, "reports", "big.bin", lockwellsdk.WithRange(0, 1023)) defer out.Body.Close() fmt.Println(out.ContentRange) ``` ```java [Java] // Range passed in the query/header map: try (var part = s3.getObjectStream("reports", "big.bin", Map.of("Range", "bytes=0-1023"))) { /* ... */ } ``` ::: :::: ### Reading a specific version On a versioned bucket, pass a `versionId` to read a past version rather than the current one. :::: native ::: code-group ```ts [Node] const old = await native.getObject("reports", "q1/summary.txt", { versionId }); ``` ```go [Go] old, _ := native.GetObject(ctx, lockwellnative.GetObjectInput{ Bucket: "reports", Key: "q1/summary.txt", VersionID: versionID, }) ``` ```java [Java] // range + versionId: try (var old = nativeClient.getObject("reports", "q1/summary.txt", null, versionId)) { /* ... */ } ``` ::: :::: :::: s3 ::: code-group ```ts [Node] const s3old = await s3.getObject("reports", "q1/summary.txt", { versionId }); ``` ```go [Go] out, _ := s3.GetObject(ctx, "reports", "q1/summary.txt", lockwellsdk.WithVersionID(versionID)) ``` ```java [Java] // versionId in the query map: var s3old = s3.getObject("reports", "q1/summary.txt", Map.of("versionId", versionId)); ``` ::: :::: ### Response-header overrides (S3 client) The S3 `GetObject` can override the headers the server returns for this one read, so you can force a download filename or a content type without rewriting the object. These are the standard S3 `response-*` query overrides. :::: s3 ::: code-group ```ts [Node] const dl = await s3.getObjectStream("reports", "q1.csv", { responseContentType: "text/csv", }); ``` ```go [Go] out, _ := s3.GetObject(ctx, "reports", "q1.csv", lockwellsdk.WithResponseContentType("text/csv"), lockwellsdk.WithResponseContentDisposition(`attachment; filename="q1.csv"`), ) ``` ```java [Java] var dl = s3.getObject("reports", "q1.csv", Map.of( "response-content-type", "text/csv", "response-content-disposition", "attachment; filename=\"q1.csv\"")); ``` ::: :::: ### Reading one multipart part (S3 client) `WithPartNumber(n)` on the S3 `GetObject` returns the byte range of a single multipart part (1-based) plus the total part count in `PartsCount`. This lets a downloader fetch the object part by part with the same boundaries it was uploaded with. :::: s3 ```go out, _ := s3.GetObject(ctx, "reports", "big.bin", lockwellsdk.WithPartNumber(1)) fmt.Println(out.PartsCount) // total parts ``` :::: ## Head an object `HeadObject` returns the same metadata as a read with no body: content type, length, ETag, version id, checksums, and encryption status. A missing object is a not-found error on both clients. :::: native ::: code-group ```ts [Node] import { isNativeNotFound } from "@kelphect/sdk"; try { const head = await native.headObject("reports", "q1/summary.txt"); console.log(head.contentLength, head.etag, head.metadata); } catch (err) { if (isNativeNotFound(err)) { /* not there */ } else throw err; } ``` ```go [Go] info, err := native.HeadObject(ctx, lockwellnative.GetObjectInput{Bucket: "reports", Key: "q1/summary.txt"}) if err != nil { if lockwellnative.IsNotFound(err) { /* not there */ } return err } fmt.Println(info.ContentLength, info.ETag) ``` ```java [Java] var head = nativeClient.headObject("reports", "q1/summary.txt"); System.out.println(head.contentLength() + " " + head.etag()); ``` ::: :::: :::: s3 ::: code-group ```ts [Node] import { isNotFound } from "@kelphect/sdk"; try { const head = await s3.headObject("reports", "q1/summary.txt"); console.log(head.contentLength, head.etag, head.metadata); } catch (err) { if (isNotFound(err)) { /* not there */ } else throw err; } ``` ```go [Go] info, err := s3.HeadObject(ctx, "reports", "q1/summary.txt") if err != nil { if lockwellsdk.IsNotFound(err) { /* not there */ } return err } fmt.Println(info.ContentLength, info.ETag, info.Metadata) ``` ```java [Java] // Returns a GetResult with an empty body: var s3head = s3.headObject("reports", "q1/summary.txt"); System.out.println(s3head.contentLength() + " " + s3head.etag()); ``` ::: :::: ## Errors Both clients raise a structured error carrying a stable code, the HTTP status, and a request id you can correlate with an audit row. | Status | Meaning | Native guard | S3 guard | | ------ | -------------------------------- | ----------------------------------------------------- | --------------------------- | | 404 | no such bucket/key | `IsNotFound` / `isNativeNotFound` | `IsNotFound` / `isNotFound` | | 401 | bad/expired token or revoked key | `IsUnauthorized` / `isNativeUnauthorized` | (re-sign) | | 403 | scope or policy denial | `IsForbidden` / `isNativeForbidden` | `APIError` code | | 409 | already exists | `IsAlreadyExists` / `isNativeConflict` | `APIError` code | | 412 | precondition not met | `IsPreconditionFailed` / `isNativePreconditionFailed` | `APIError` code | | 507 | tenant storage quota exceeded | `IsQuotaExceeded` / `isNativeQuotaExceeded` | `APIError` code | In Java, catch `NativeException` (native) or `ApiException` (S3) and branch on `statusCode()` / `code()`. Full handling, including the retry policy, is on [Errors & retries](/guide/errors-and-retries). ## Next steps * [Listing & pagination](/guide/listing-objects): find the objects you stored. * [Copying objects](/guide/copying-objects): server-side copy, conditionals, large-object part copy. * [Deleting objects](/guide/deleting-objects): single, batch, and versioned deletes. * [Conditional writes](/guide/conditional-writes): create-only, overwrite-only, idempotency. * [Multipart uploads](/guide/multipart-uploads): resumable uploads for very large objects. * [Signed URLs](/guide/signed-urls): hand a browser a direct upload or download URL. --- --- url: /guide/listing-objects.md description: >- List the keys in a Lockwell bucket with prefix, delimiter, and common-prefix semantics, plus full pagination loops on the native and S3 clients. --- # Listing & pagination Listing walks the keys in a bucket. This page covers both clients, the delimiter and common-prefix model, and full paging loops. * The **native client** has `listObjects` with prefix/delimiter semantics and an iterator that follows continuation tokens. * The **S3 client** has two forms: `ListObjectsV2` (the token-paged default) and `ListObjects` (the older marker-paged v1), plus paginators that follow the tokens for you. For the operation matrix, see the [native data-plane reference](/reference/native-api) and the [S3 operations reference](/reference/s3-operations#listing). ## One page A single listing call returns one page. `prefix` filters by key prefix; `maxKeys` caps the page (the server caps it at 1000\); the page reports `isTruncated` and a `nextContinuationToken` when more remains. :::: native ::: code-group ```ts [Node] const page = await native.listObjects("reports", { prefix: "q1/", maxKeys: 100 }); for (const o of page.objects) console.log(o.key, o.size); console.log(page.isTruncated, page.nextContinuationToken); ``` ```go [Go] page, err := native.ListObjects(ctx, lockwellnative.ListObjectsInput{ Bucket: "reports", Prefix: "q1/", MaxKeys: 100, }) for _, o := range page.Objects { fmt.Println(o.Key, o.Size, o.ETag) } fmt.Println(page.IsTruncated, page.NextContinuationToken) ``` ```java [Java] // ListObjectsOptions record: prefix, delimiter, continuationToken, maxKeys. import com.lockwell.sdk.nativeapi.NativeTypes.ListObjectsOptions; var page = nativeClient.listObjects("reports", new ListObjectsOptions("q1/", null, null, 100)); page.objects().forEach(o -> System.out.println(o.key() + " " + o.size())); System.out.println(page.isTruncated() + " " + page.nextContinuationToken()); ``` ::: :::: :::: s3 The S3 `ListObjectsV2` returns the same page shape: ::: code-group ```ts [Node] const page = await s3.listObjectsV2("reports", { prefix: "q1/", maxKeys: 100 }); for (const o of page.objects) console.log(o.key, o.size, o.etag); console.log(page.isTruncated, page.nextContinuationToken); ``` ```go [Go] page, err := s3.ListObjectsV2(ctx, "reports", lockwellsdk.WithPrefix("q1/"), lockwellsdk.WithMaxKeys(100)) for _, o := range page.Objects { fmt.Println(o.Key, o.Size, o.ETag) } fmt.Println(page.IsTruncated, page.NextContinuationToken) ``` ```java [Java] // listObjectsV2(bucket, prefix, maxKeys, continuationToken) var page = s3.listObjectsV2("reports", "q1/", 100, null); page.objects().forEach(o -> System.out.println(o.key() + " " + o.size())); System.out.println(page.truncated() + " " + page.nextContinuationToken()); ``` ::: :::: ### Listing options | Option | Native (Go / Node / Java) | S3 (Go / Node / Java) | | ------------------ | ----------------------------------------------------------- | ----------------------------------------------------- | | Key prefix | `Prefix` / `prefix` / `ListObjectsOptions.prefix` | `WithPrefix` / `prefix` / arg 2 | | Delimiter | `Delimiter` / `delimiter` / `ListObjectsOptions.delimiter` | `WithDelimiter` / `delimiter` / v1 form | | Start after a key | `StartAfter` (Go) / `startAfter` / `startAfter` on the wire | `WithStartAfter` / `startAfter` | | Continuation token | `ContinuationToken` / `continuationToken` / arg 3 | `WithContinuationToken` / `continuationToken` / arg 4 | | Max keys per page | `MaxKeys` / `maxKeys` / `ListObjectsOptions.maxKeys` | `WithMaxKeys` / `maxKeys` / arg 3 | `startAfter` begins the listing after a given key (a one-shot starting point, not a resume token). `continuationToken` resumes a previously truncated listing. ::: warning Pass `startAfter` or `continuationToken`, not both. They serve different purposes: one sets a starting key, the other resumes a paged listing. ::: ## Delimiters and common prefixes A `delimiter` (almost always `"/"`) makes the listing behave like a directory walk. Keys that share a prefix up to the next delimiter are collapsed into a single entry in `commonPrefixes`; only keys with no further delimiter past the prefix appear in `objects`. Given these keys: ```text logs/2026/01/a.log logs/2026/01/b.log logs/2026/02/c.log logs/index.txt ``` a listing with `prefix: "logs/"` and `delimiter: "/"` returns: * `objects`: `logs/index.txt` * `commonPrefixes`: `logs/2026/` To descend, list again with the common prefix as the new `prefix`. Without a delimiter, the listing is flat: every key under the prefix comes back in `objects`. :::: native ::: code-group ```ts [Node] const page = await native.listObjects("archive", { prefix: "logs/", delimiter: "/" }); console.log(page.commonPrefixes); // ["logs/2026/"] for (const o of page.objects) console.log(o.key); // "logs/index.txt" ``` ```go [Go] page, _ := native.ListObjects(ctx, lockwellnative.ListObjectsInput{ Bucket: "archive", Prefix: "logs/", Delimiter: "/", }) fmt.Println(page.CommonPrefixes) // ["logs/2026/"] for _, o := range page.Objects { fmt.Println(o.Key) // "logs/index.txt" } ``` ```java [Java] import com.lockwell.sdk.nativeapi.NativeTypes.ListObjectsOptions; // Native carries a delimiter directly: var page = nativeClient.listObjects("archive", new ListObjectsOptions("logs/", "/", null, null)); System.out.println(page.commonPrefixes()); page.objects().forEach(o -> System.out.println(o.key())); ``` ::: :::: :::: s3 ::: code-group ```ts [Node] const page = await s3.listObjectsV2("archive", { prefix: "logs/", delimiter: "/" }); console.log(page.commonPrefixes); // ["logs/2026/"] for (const o of page.objects) console.log(o.key); // "logs/index.txt" ``` ```go [Go] page, _ := s3.ListObjectsV2(ctx, "archive", lockwellsdk.WithPrefix("logs/"), lockwellsdk.WithDelimiter("/")) fmt.Println(page.CommonPrefixes) // ["logs/2026/"] for _, o := range page.Objects { fmt.Println(o.Key) // "logs/index.txt" } ``` ```java [Java] // The S3 v1 listObjects carries the delimiter as its last argument: var page = s3.listObjects("archive", "logs/", null, 1000, "/"); System.out.println(page.commonPrefixes()); page.objects().forEach(o -> System.out.println(o.key())); ``` ::: :::: ## Paging across all keys To walk every key, follow `isTruncated` with the continuation token until it clears. The native client ships an iterator that does this for you; the S3 client ships paginators per listing operation. The native iterator (`ListObjectsAll` in Go, the `paginateObjects` async generator in Node) transparently follows continuation tokens. :::: native ::: code-group ```ts [Node] for await (const page of native.paginateObjects("reports", { prefix: "q1/" })) { for (const o of page.objects) console.log(o.key, o.size); } ``` ```go [Go] it := native.ListObjectsAll(ctx, lockwellnative.ListObjectsInput{Bucket: "reports", Prefix: "q1/"}) for it.Next() { o := it.Object() fmt.Println(o.Key, o.Size) } if err := it.Err(); err != nil { return err } ``` ```java [Java] // Page the native listing with the continuation token: import com.lockwell.sdk.nativeapi.NativeTypes.ListObjectsOptions; String token = null; do { var page = nativeClient.listObjects("reports", new ListObjectsOptions("q1/", null, token, null)); page.objects().forEach(o -> System.out.println(o.key() + " " + o.size())); token = page.isTruncated() ? page.nextContinuationToken() : null; } while (token != null); ``` ::: :::: :::: s3 The S3 client ships an auto-pager for each listing operation, so you never thread a continuation token by hand. The loop shape is the same in every language: ask whether more pages remain, fetch the next page, repeat. ::: code-group ```ts [Node] // Node uses async iterators rather than a paginator object: for await (const page of s3.paginateObjectsV2("reports", { prefix: "q1/" })) { for (const o of page.objects) console.log(o.key, o.size); } ``` ```go [Go] p := s3.NewListObjectsV2Paginator("reports", lockwellsdk.WithPrefix("q1/")) for p.HasMorePages() { page, err := p.NextPage(ctx) if err != nil { return err } for _, o := range page.Objects { fmt.Println(o.Key, o.Size) } } ``` ```java [Java] // The S3 client exposes paginators for versions, multipart uploads, and parts. // For a flat object listing, page with the continuation token: String token = null; do { var page = s3.listObjectsV2("reports", "q1/", 1000, token); page.objects().forEach(o -> System.out.println(o.key() + " " + o.size())); token = page.truncated() ? page.nextContinuationToken() : null; } while (token != null); ``` ::: The Go paginators (`NewListObjectsV2Paginator`, `NewListObjectVersionsPaginator`, `NewListMultipartUploadsPaginator`, `NewListPartsPaginator`) own the tokens. Seed them with the same options the one-shot method takes, but do not pass a continuation/marker yourself: the paginator manages it. :::: ## Paging by hand If you would rather drive the loop yourself, follow `isTruncated` with the continuation token until it clears. :::: native ::: code-group ```ts [Node] let token; do { const page = await native.listObjects( "reports", token ? { prefix: "q1/", continuationToken: token } : { prefix: "q1/" }, ); for (const o of page.objects) console.log(o.key); token = page.isTruncated ? page.nextContinuationToken : undefined; } while (token); ``` ```go [Go] var token string for { in := lockwellnative.ListObjectsInput{Bucket: "reports", Prefix: "q1/", ContinuationToken: token} page, err := native.ListObjects(ctx, in) if err != nil { return err } for _, o := range page.Objects { fmt.Println(o.Key) } if !page.IsTruncated || page.NextContinuationToken == "" { break } token = page.NextContinuationToken } ``` ```java [Java] import com.lockwell.sdk.nativeapi.NativeTypes.ListObjectsOptions; String token = null; do { var page = nativeClient.listObjects("reports", new ListObjectsOptions("q1/", null, token, null)); page.objects().forEach(o -> System.out.println(o.key())); token = page.isTruncated() ? page.nextContinuationToken() : null; } while (token != null); ``` ::: :::: :::: s3 ::: code-group ```ts [Node] let token; do { const page = await s3.listObjectsV2( "reports", token ? { prefix: "q1/", continuationToken: token } : { prefix: "q1/" }, ); for (const o of page.objects) console.log(o.key); token = page.isTruncated ? page.nextContinuationToken : undefined; } while (token); ``` ```go [Go] var token string for { opts := []lockwellsdk.ListOption{lockwellsdk.WithPrefix("q1/")} if token != "" { opts = append(opts, lockwellsdk.WithContinuationToken(token)) } page, err := s3.ListObjectsV2(ctx, "reports", opts...) if err != nil { return err } for _, o := range page.Objects { fmt.Println(o.Key) } if !page.IsTruncated || page.NextContinuationToken == "" { break } token = page.NextContinuationToken } ``` ```java [Java] String token = null; do { var page = s3.listObjectsV2("reports", "q1/", 1000, token); page.objects().forEach(o -> System.out.println(o.key())); token = page.truncated() ? page.nextContinuationToken() : null; } while (token != null); ``` ::: :::: ## ListObjects v1 (marker pagination) The S3 client also exposes the v1 `ListObjects`, which pages by a `marker` (the last key returned) rather than an opaque token. Prefer `ListObjectsV2` (or the native listing) for new code; v1 exists for parity with clients that still page by marker. :::: s3 ::: code-group ```ts [Node] let marker; do { const page = await s3.listObjects("reports", marker ? { prefix: "v1/", marker } : { prefix: "v1/" }); for (const o of page.objects) console.log(o.key); marker = page.isTruncated ? page.nextMarker || page.objects.at(-1)?.key : undefined; } while (marker); ``` ```go [Go] page1, _ := s3.ListObjects(ctx, "reports", lockwellsdk.WithPrefix("v1/"), lockwellsdk.WithMaxKeys(2)) page2, _ := s3.ListObjects(ctx, "reports", lockwellsdk.WithPrefix("v1/"), lockwellsdk.WithMaxKeys(2), lockwellsdk.WithMarker(page1.NextMarker)) ``` ```java [Java] // listObjects(bucket, prefix, marker, maxKeys, delimiter) var page1 = s3.listObjects("reports", "v1/", null, 2, null); var page2 = s3.listObjects("reports", "v1/", page1.nextMarker(), 2, null); ``` ::: :::: When a delimiter collapses the truncation boundary onto a common prefix, the server may omit `NextMarker`. In that case resume from the last key or common prefix you saw, which is what the Node and Go paginators do for you. ## Listing versions and multipart uploads Versioned listings (`ListObjectVersions`) and in-progress multipart uploads (`ListMultipartUploads`, `ListParts`) share this prefix/delimiter/marker model. They each have a dedicated page: * [Versioning](/guide/versioning): list versions and delete markers. * [Multipart uploads](/guide/multipart-uploads): list uploads and their parts. ## Next steps * [Upload & download](/guide/data-operations): the put/get/head you are listing. * [Copying objects](/guide/copying-objects): duplicate keys you found in a listing. * [Deleting objects](/guide/deleting-objects): batch-delete a listed prefix. --- --- url: /guide/copying-objects.md description: >- Duplicate objects server-side with Lockwell CopyObject, including cross-bucket copies, source and destination conditionals, and large-object UploadPartCopy. --- # Copying objects `CopyObject` duplicates an object server-side, so the bytes never travel back to your process. Use it to move a key, branch a version, change metadata, or apply encryption on the copy. For objects too large to copy in one request, the S3 `UploadPartCopy` assembles a new object from ranges of existing ones. Copies stay within the tenant. On the native path the source resolves under the token's tenant, so a cross-tenant copy is impossible by construction. For the operation matrix, see the [native data-plane reference](/reference/native-api) and the [S3 operations reference](/reference/s3-operations#multipart-uploads). ## Copy within a bucket Name the destination (bucket, key) and the source (bucket, key, and an optional source version id). Leave the source version unset to copy the current version. :::: native ::: code-group ```ts [Node] const res = await native.copyObject("reports", "q1/summary-copy.txt", { sourceBucket: "reports", sourceKey: "q1/summary.txt", }); console.log(res.etag, res.versionId); ``` ```go [Go] res, err := native.CopyObject(ctx, lockwellnative.CopyObjectInput{ Bucket: "reports", Key: "q1/summary-copy.txt", SourceBucket: "reports", SourceKey: "q1/summary.txt", }) fmt.Println(res.ETag, res.VersionID) ``` ```java [Java] import com.lockwell.sdk.nativeapi.NativeTypes.CopyOptions; // copyObject(destBucket, destKey, sourceBucket, sourceKey, opts) var res = nativeClient.copyObject("reports", "q1/summary-copy.txt", "reports", "q1/summary.txt", new CopyOptions()); System.out.println(res.etag() + " " + res.versionId()); ``` ::: :::: :::: s3 The S3 client names the source first, then the destination: ::: code-group ```ts [Node] const res = await s3.copyObject("reports", "q1/summary.txt", "", "reports", "q1/summary-copy.txt"); console.log(res.etag, res.versionId); ``` ```go [Go] // CopyObject(srcBucket, srcKey, srcVersionID, dstBucket, dstKey, opts...) res, err := s3.CopyObject(ctx, "reports", "q1/summary.txt", "", "reports", "q1/summary-copy.txt") fmt.Println(res.ETag, res.VersionID) ``` ```java [Java] // copyObject(srcBucket, srcKey, srcVersionId, dstBucket, dstKey, ifMatch) var res = s3.copyObject("reports", "q1/summary.txt", null, "reports", "q1/summary-copy.txt", null); System.out.println(res.etag() + " " + res.versionId()); ``` ::: :::: ## Copy across buckets The destination bucket can differ from the source bucket. Both must belong to the same tenant. :::: native ::: code-group ```ts [Node] await native.copyObject("archive", "imports/upload.csv", { sourceBucket: "inbox", sourceKey: "upload.csv", }); ``` ```go [Go] _, err := native.CopyObject(ctx, lockwellnative.CopyObjectInput{ Bucket: "archive", Key: "imports/upload.csv", SourceBucket: "inbox", SourceKey: "upload.csv", }) ``` ```java [Java] nativeClient.copyObject("archive", "imports/upload.csv", "inbox", "upload.csv", new CopyOptions()); ``` ::: :::: :::: s3 ::: code-group ```ts [Node] await s3.copyObject("inbox", "upload.csv", "", "archive", "imports/upload.csv"); ``` ```go [Go] _, err := s3.CopyObject(ctx, "inbox", "upload.csv", "", "archive", "imports/upload.csv") ``` ```java [Java] s3.copyObject("inbox", "upload.csv", null, "archive", "imports/upload.csv", null); ``` ::: :::: ## Copy-source conditionals Gate the copy on the state of the **source** object. The copy proceeds only if the condition holds; otherwise it fails with a `412 PreconditionFailed`. | Condition | Effect | | --------------------------------- | ------------------------------------------------------- | | If-Match `` | Copy only if the source ETag matches. | | If-None-Match `` | Copy only if the source ETag does not match. | | If-Modified-Since `` | Copy only if the source changed since the date. | | If-Unmodified-Since `` | Copy only if the source has not changed since the date. | The native client takes all four as `ifMatch` / `ifNoneMatch` / `ifModifiedSince` / `ifUnmodifiedSince`. The S3 client takes them as `WithCopyIf*` options. ::: warning The Java S3 `copyObject` accepts only an `ifMatch` argument. For the other three source conditionals on Java, use the native client. ::: :::: native ::: code-group ```ts [Node] await native.copyObject("reports", "q1/pinned.txt", { sourceBucket: "reports", sourceKey: "q1/summary.txt", ifUnmodifiedSince: "Wed, 01 Jan 2026 00:00:00 GMT", }); ``` ```go [Go] _, err := native.CopyObject(ctx, lockwellnative.CopyObjectInput{ Bucket: "reports", Key: "q1/pinned.txt", SourceBucket: "reports", SourceKey: "q1/summary.txt", IfUnmodifiedSince: "Wed, 01 Jan 2026 00:00:00 GMT", }) ``` ```java [Java] // The native client covers all four source conditionals: nativeClient.copyObject("reports", "q1/pinned.txt", "reports", "q1/summary.txt", new CopyOptions().ifUnmodifiedSince("Wed, 01 Jan 2026 00:00:00 GMT")); ``` ::: :::: :::: s3 ::: code-group ```ts [Node] // Copy only if the source still has the ETag we expect: await s3.copyObject("reports", "q1/summary.txt", "", "reports", "q1/pinned.txt", { ifMatch: '"d41d8cd98f00b204e9800998ecf8427e"', }); ``` ```go [Go] _, err := s3.CopyObject(ctx, "reports", "q1/summary.txt", "", "reports", "q1/pinned.txt", lockwellsdk.WithCopyIfMatch(`"d41d8cd98f00b204e9800998ecf8427e"`), ) if lockwellsdk.IsNotFound(err) { /* ... */ } ``` ```java [Java] // S3 takes ifMatch directly (the last argument): var res = s3.copyObject("reports", "q1/summary.txt", null, "reports", "q1/pinned.txt", "\"d41d8cd98f00b204e9800998ecf8427e\""); ``` ::: :::: ### Destination conditionals (native client) The native copy also gates on the **destination** atomically at the commit: `requireAbsent` copies only when the destination key does not exist (a copy-time create-only), and `requireMatchEtag` copies only when the destination's current ETag matches (a copy-time overwrite-only). These pair naturally with the [conditional write](/guide/conditional-writes) patterns. ::: code-group ```ts [Node] // Branch a snapshot, but never clobber an existing one: await native.copyObject("snapshots", "latest.json", { sourceBucket: "live", sourceKey: "state.json", requireAbsent: true, // 412 if snapshots/latest.json already exists }); ``` ```go [Go] _, err := native.CopyObject(ctx, lockwellnative.CopyObjectInput{ Bucket: "snapshots", Key: "latest.json", SourceBucket: "live", SourceKey: "state.json", RequireAbsent: true, // 412 if snapshots/latest.json already exists }) ``` ```java [Java] // ifAbsent() sets the destination require-absent precondition: nativeClient.copyObject("snapshots", "latest.json", "live", "state.json", new CopyOptions().ifAbsent()); // 412 if snapshots/latest.json already exists ``` ::: ## Metadata directive: COPY vs REPLACE By default a copy inherits the source's user metadata and content type (`metadataDirective: "COPY"`). Pass `REPLACE` to set fresh metadata and content type from the request instead. On the native client, set the directive explicitly. On the S3 client, supplying copy metadata implies `REPLACE` (the SDK sets the directive for you when you pass metadata). :::: native ::: code-group ```ts [Node] // Explicit REPLACE with a new content type + metadata: await native.copyObject("reports", "q1/tagged.txt", { sourceBucket: "reports", sourceKey: "q1/summary.txt", metadataDirective: "REPLACE", contentType: "text/plain; charset=utf-8", metadata: { reviewed: "true" }, }); ``` ```go [Go] _, err := native.CopyObject(ctx, lockwellnative.CopyObjectInput{ Bucket: "reports", Key: "q1/tagged.txt", SourceBucket: "reports", SourceKey: "q1/summary.txt", MetadataDirective: "REPLACE", ContentType: "text/plain; charset=utf-8", Metadata: map[string]string{"reviewed": "true"}, }) ``` ```java [Java] import java.util.Map; // replaceMetadata sets the directive to REPLACE and the new values: nativeClient.copyObject("reports", "q1/tagged.txt", "reports", "q1/summary.txt", new CopyOptions().replaceMetadata("text/plain; charset=utf-8", Map.of("reviewed", "true"))); ``` ::: :::: :::: s3 ::: code-group ```ts [Node] // Passing metadata replaces the destination's metadata (sets REPLACE): await s3.copyObject("reports", "q1/summary.txt", "", "reports", "q1/tagged.txt", { metadata: { reviewed: "true" }, }); ``` ```go [Go] // WithCopyMetadata replaces the destination metadata (sets REPLACE): _, err := s3.CopyObject(ctx, "reports", "q1/summary.txt", "", "reports", "q1/tagged.txt", lockwellsdk.WithCopyMetadata(map[string]string{"reviewed": "true"}), ) ``` ```java [Java] import java.util.Map; // The S3 copyObject does not take a metadata argument; use the native client for // a metadata-replacing copy. ``` ::: :::: With `COPY` (the default), the source's content type and every `X-Lockwell-Meta-*` / `x-amz-meta-*` entry carry over unchanged. ## Encryption on the copy (S3 client) The native store is always encrypted at rest, so the native copy has no SSE option. On the S3 client `WithCopyServerSideEncryption` / `serverSideEncryption: true` requests SSE-S3 for the destination object, so you can encrypt a previously unencrypted object by copying it onto itself or to a new key. :::: s3 ```go _, err := s3.CopyObject(ctx, "vault", "ledger.json", "", "vault", "ledger.json", lockwellsdk.WithCopyServerSideEncryption(), ) ``` :::: ## Large objects: UploadPartCopy (S3 client) A single `CopyObject` copies the whole object in one request. For an object too large for that, or to assemble a new object from ranges of existing ones, copy **parts**: start a multipart upload on the destination, then call `UploadPartCopy` for each part, naming a source object and an optional byte range. Complete the upload with the returned part ETags. `UploadPartCopy` lives on the S3 client. The native multipart path uploads part bytes rather than copying ranges; see [multipart uploads](/guide/multipart-uploads). Each part except the last must meet the multipart minimum part size. :::: s3 ::: code-group ```ts [Node] const { uploadId } = await s3.createMultipartUpload("archive", "merged.bin"); // Each part copies a byte range from a source object (no bytes through your app): const p1 = await s3.uploadPartCopy("inbox", "a.bin", "", "archive", "merged.bin", uploadId, 1, "bytes=0-5242879"); const p2 = await s3.uploadPartCopy("inbox", "b.bin", "", "archive", "merged.bin", uploadId, 2, ""); // whole object await s3.completeMultipartUpload("archive", "merged.bin", uploadId, [ { partNumber: 1, etag: p1.etag }, { partNumber: 2, etag: p2.etag }, ]); ``` ```go [Go] mu, _ := s3.CreateMultipartUpload(ctx, "archive", "merged.bin") // UploadPartCopy(srcBucket, srcKey, srcVersionID, dstBucket, dstKey, uploadID, partNumber, byteRange) e1, _ := s3.UploadPartCopy(ctx, "inbox", "a.bin", "", "archive", "merged.bin", mu.UploadID, 1, "bytes=0-5242879") e2, _ := s3.UploadPartCopy(ctx, "inbox", "b.bin", "", "archive", "merged.bin", mu.UploadID, 2, "") // whole object _, _ = s3.CompleteMultipartUpload(ctx, "archive", "merged.bin", mu.UploadID, []lockwellsdk.CompletedPart{{PartNumber: 1, ETag: e1.ETag}, {PartNumber: 2, ETag: e2.ETag}}) ``` ```java [Java] import java.util.List; var mu = s3.createMultipartUpload("archive", "merged.bin", null); // uploadPartCopy(bucket, key, uploadId, partNumber, srcBucket, srcKey, srcVersionId, copySourceRange) String e1 = s3.uploadPartCopy("archive", "merged.bin", mu.uploadId(), 1, "inbox", "a.bin", null, "bytes=0-5242879"); String e2 = s3.uploadPartCopy("archive", "merged.bin", mu.uploadId(), 2, "inbox", "b.bin", null, null); s3.completeMultipartUpload("archive", "merged.bin", mu.uploadId(), null, List.of(e1, e2)); ``` ::: :::: ## Next steps * [Multipart uploads](/guide/multipart-uploads): the full upload-by-parts flow. * [Conditional writes](/guide/conditional-writes): the create-only / overwrite-only patterns the destination conditionals mirror. * [Object tags](/guide/object-tags): tag the copies you make. * [Deleting objects](/guide/deleting-objects): remove the source after a move. --- --- url: /guide/deleting-objects.md description: >- Delete one object, a specific version, or up to 1000 objects in a batch with Lockwell, and understand how delete markers interact with versioning. --- # Deleting objects Delete one object, a specific version, or up to 1000 objects in a single batch request. On a versioned bucket a delete usually does not remove bytes. It writes a *delete marker* that hides the object while its prior versions stay recoverable. This page covers single deletes, batch deletes with per-key results, and how delete interacts with versioning. For the operation matrix, see the [native data-plane reference](/reference/native-api) and the [S3 operations reference](/reference/s3-operations#objects-write). ## Delete one object `DeleteObject` removes the current object (on an unversioned bucket) or writes a delete marker (on a versioned one). A delete blocked by Object Lock retention or a legal hold fails with S3 `403 AccessDenied`; the native API returns `412` with `retention_blocked` or `legal_hold_blocked`. The native client reports whether a delete marker was created. :::: native ::: code-group ```ts [Node] const res = await native.deleteObject("reports", "q1/summary.txt"); console.log(res.deleteMarker, res.versionId); ``` ```go [Go] res, err := native.DeleteObject(ctx, "reports", "q1/summary.txt", "" /* versionID */) fmt.Println(res.DeleteMarker, res.DeleteMarkerVersionID) ``` ```java [Java] // Returns a DeleteResult: var res = nativeClient.deleteObject("reports", "q1/summary.txt"); System.out.println(res.deleteMarker()); ``` ::: :::: :::: s3 The same with the S3 client: ::: code-group ```ts [Node] await s3.deleteObject("reports", "q1/summary.txt"); ``` ```go [Go] err := s3.DeleteObject(ctx, "reports", "q1/summary.txt") ``` ```java [Java] s3.deleteObject("reports", "q1/summary.txt"); ``` ::: :::: A delete is idempotent: deleting a key that is already gone succeeds. Deleting a missing key does not raise a not-found error. ## Delete a specific version Pass a `versionId` to delete one exact version rather than the current object. On a versioned bucket this is a hard delete of that version (it does not create a delete marker), so use it to prune history or to remove a delete marker and restore the prior version. :::: native ::: code-group ```ts [Node] await native.deleteObject("reports", "q1/summary.txt", { versionId }); ``` ```go [Go] res, err := native.DeleteObject(ctx, "reports", "q1/summary.txt", versionID) ``` ```java [Java] nativeClient.deleteObject("reports", "q1/summary.txt", versionId); ``` ::: :::: :::: s3 ::: code-group ```ts [Node] await s3.deleteObject("reports", "q1/summary.txt", { versionId }); ``` ```go [Go] err := s3.DeleteObject(ctx, "reports", "q1/summary.txt", lockwellsdk.WithVersionID(versionID)) ``` ```java [Java] s3.deleteObject("reports", "q1/summary.txt", versionId); ``` ::: :::: Deleting the version id of a delete marker removes the marker, so the prior version becomes current again. List versions first to find the ids; see [versioning](/guide/versioning). ## Batch delete `DeleteObjects` removes up to **1000** objects in one request. The batch may **partially succeed**: the server reports per-key successes and per-key failures separately, so a single denied or retention-locked key does not fail the whole call. The SDKs reject an over-1000 batch locally with a clear error before any request goes out. ::: warning A batch delete can partially succeed without raising an error. Always walk the errors list to see which keys failed; the call itself does not throw on a per-key denial. ::: :::: native ::: code-group ```ts [Node] const res = await native.batchDeleteObjects("reports", ["q1/a.txt", { key: "q1/b.txt", versionId }]); for (const d of res.deleted) console.log("deleted", d.key, d.deleteMarker); for (const e of res.errors) console.log("failed", e.key, e.code, e.message); ``` ```go [Go] out, err := native.BatchDeleteObjects(ctx, "reports", []lockwellnative.BatchDeleteKey{ {Key: "q1/a.txt"}, {Key: "q1/b.txt", VersionID: versionID}, }) for _, d := range out.Deleted { fmt.Println("deleted", d.Key, d.DeleteMarker) } for _, e := range out.Errors { log.Printf("delete %s failed: %s %s", e.Key, e.Code, e.Message) } ``` ```java [Java] import com.lockwell.sdk.nativeapi.NativeTypes; import java.util.List; var out = nativeClient.batchDeleteObjects("reports", List.of( new NativeTypes.ObjectIdentifier("q1/a.txt"), new NativeTypes.ObjectIdentifier("q1/b.txt", versionId))); out.deleted().forEach(d -> System.out.println("deleted " + d.key())); out.errors().forEach(e -> System.out.println("failed " + e.key() + " " + e.code())); ``` ::: :::: :::: s3 The S3 batch entries are a key string or `{ key, versionId }`: ::: code-group ```ts [Node] const res = await s3.deleteObjects("reports", ["q1/a.txt", { key: "q1/b.txt", versionId }]); for (const d of res.deleted) console.log("deleted", d.key, d.deleteMarker); for (const e of res.errors) console.log("failed", e.key, e.code, e.message); ``` ```go [Go] out, err := s3.DeleteObjects(ctx, "reports", []lockwellsdk.ObjectIdentifier{ {Key: "q1/a.txt"}, {Key: "q1/b.txt", VersionID: versionID}, }) for _, d := range out.Deleted { fmt.Println("deleted", d.Key, d.DeleteMarker) } for _, e := range out.Errors { log.Printf("delete %s failed: %s %s", e.Key, e.Code, e.Message) } ``` ```java [Java] import com.lockwell.sdk.LockwellClient.ObjectIdentifier; import java.util.List; // deleteObjects(bucket, identifiers, quiet); quiet = false here: var out = s3.deleteObjects("reports", List.of( new ObjectIdentifier("q1/a.txt"), new ObjectIdentifier("q1/b.txt", versionId)), false); out.deleted().forEach(d -> System.out.println("deleted " + d.key())); out.errors().forEach(e -> System.out.println("failed " + e.key() + " " + e.code())); ``` ::: :::: ### Quiet mode (S3 client) By default the S3 batch response lists every successfully deleted key. Quiet mode suppresses those success entries and returns only the failures, which keeps the response small when you are deleting thousands of keys and only care about what went wrong. The server still deletes every key; only the response shrinks. Errors are always returned regardless of quiet mode. :::: s3 ::: code-group ```ts [Node] const res = await s3.deleteObjects("reports", keys, { quiet: true }); // res.deleted is empty; res.errors holds any per-key failures. ``` ```go [Go] out, err := s3.DeleteObjects(ctx, "reports", ids, lockwellsdk.WithQuietDelete()) // out.Deleted is empty; inspect out.Errors. ``` ```java [Java] // The third argument is the quiet flag: var out = s3.deleteObjects("reports", ids, true); ``` ::: :::: ### Per-key results Each successful entry reports the key, and on a versioned bucket whether the delete created a delete marker plus that marker's version id. Each failed entry reports the key, an error code, and a message. Walk both lists after a batch: ```ts const res = await native.batchDeleteObjects("reports", keys); if (res.errors.length > 0) { // Retry or surface the failures; the rest were deleted. for (const e of res.errors) console.error(`${e.key}: ${e.code} ${e.message}`); } ``` A retention-locked or legal-held key shows up in `errors` (forbidden), not as a thrown call error, so one protected object never blocks the rest of the batch. ## Delete and versioning What a delete does depends on the bucket's versioning state: | Bucket state | `DeleteObject` (no version id) | `DeleteObject` (with version id) | | -------------------- | ------------------------------------------------------------ | ------------------------------------ | | Versioning disabled | Removes the object's bytes. | Removes that version (the only one). | | Versioning enabled | Writes a delete marker; prior versions stay recoverable. | Hard-deletes that exact version. | | Versioning suspended | Writes a null-version delete marker; existing versions stay. | Hard-deletes that version. | After a delete-marker delete, a plain `GetObject` returns not-found (the marker hides the object), but the prior version is still readable by its version id and recoverable by deleting the marker. To wipe an object and all its history, delete every version id (list versions, then batch-delete with version ids). :::: native ::: code-group ```ts [Node] // Wipe a key and all its versions on a versioned bucket: const ids = []; for await (const page of native.paginateObjectVersions("reports", { prefix: "q1/summary.txt" })) { for (const v of page.versions) ids.push({ key: v.key, versionId: v.versionId }); } await native.batchDeleteObjects("reports", ids); ``` ```go [Go] // List every version, then batch-delete by version id: page, _ := native.ListObjectVersions(ctx, lockwellnative.ListObjectVersionsInput{ Bucket: "reports", Prefix: "q1/summary.txt", }) var ids []lockwellnative.BatchDeleteKey for _, v := range page.Versions { ids = append(ids, lockwellnative.BatchDeleteKey{Key: v.Key, VersionID: v.VersionID}) } _, _ = native.BatchDeleteObjects(ctx, "reports", ids) ``` ```java [Java] import com.lockwell.sdk.nativeapi.NativeTypes; import com.lockwell.sdk.nativeapi.NativeTypes.ListVersionsOptions; import java.util.ArrayList; import java.util.List; var page = nativeClient.listObjectVersions("reports", new ListVersionsOptions("q1/summary.txt", null, null, null)); List ids = new ArrayList<>(); page.versions().forEach(v -> ids.add(new NativeTypes.ObjectIdentifier(v.key(), v.versionId()))); nativeClient.batchDeleteObjects("reports", ids); ``` ::: :::: :::: s3 ::: code-group ```ts [Node] // Same wipe with the S3 client's version paginator: const ids = []; for await (const page of s3.paginateObjectVersions("reports", { prefix: "q1/summary.txt" })) { for (const v of page.versions) ids.push({ key: v.key, versionId: v.versionId }); } await s3.deleteObjects("reports", ids); ``` ```go [Go] p := s3.NewListObjectVersionsPaginator("reports", lockwellsdk.WithVersionsPrefix("q1/summary.txt")) var ids []lockwellsdk.ObjectIdentifier for p.HasMorePages() { page, _ := p.NextPage(ctx) for _, v := range page.Versions { ids = append(ids, lockwellsdk.ObjectIdentifier{Key: v.Key, VersionID: v.VersionID}) } } _, _ = s3.DeleteObjects(ctx, "reports", ids) ``` ```java [Java] import com.lockwell.sdk.LockwellClient.ListVersionsOptions; import com.lockwell.sdk.LockwellClient.ObjectIdentifier; import java.util.ArrayList; import java.util.List; var pager = s3.listObjectVersionsPaginator("reports", new ListVersionsOptions().prefix("q1/summary.txt")); List ids = new ArrayList<>(); while (pager.hasMorePages()) { pager.nextPage().versions().forEach(v -> ids.add(new ObjectIdentifier(v.key(), v.versionId()))); } s3.deleteObjects("reports", ids, false); ``` ::: :::: ## Deleting buckets `DeleteBucket` removes an empty bucket. The server rejects a delete of a bucket that still holds objects (or, on a versioned bucket, versions or delete markers), so empty it first with a listing-driven batch delete. Bucket lifecycle is covered in [Versioning](/guide/versioning) and the SDK references. ## Next steps * [Versioning](/guide/versioning): list versions and delete markers, restore a deleted object. * [Listing & pagination](/guide/listing-objects): gather the keys to batch-delete. * [Object lock](/guide/object-lock): why a retention-locked object refuses deletion. --- --- url: /guide/conditional-writes.md description: >- Gate Lockwell writes on an object's current state with If-None-Match and If-Match, and make retried writes replay-safe with idempotency keys. --- # Conditional writes & idempotency A conditional write gates a write on the object's current state, so two writers racing the same key cannot silently clobber each other. An idempotency key makes a retried write safe to replay without writing twice. This page covers both, where each lives across the clients, and the safe-retry patterns to reach for. The headline distinction: * **Conditional writes (`If-None-Match` / `If-Match`) are a native-client feature.** The native `putObject` takes them directly. The S3 `PutObject` does not, so use the native client or a conditional [copy](/guide/copying-objects) for an existing object. * **Idempotency keys work on both clients.** ## Create-only: `If-None-Match: "*"` `If-None-Match: "*"` writes only when the key is **absent**. If something is already there, the write fails with a `412` precondition error and nothing is overwritten. This is the atomic "create, do not clobber" primitive: two callers racing to create the same key, exactly one wins. It is a native-client write, so it always shows below the surface toggle. ::: code-group ```ts [Node] import { isNativePreconditionFailed } from "@kelphect/sdk"; try { await native.putObject("reports", "q1/summary.txt", body, { ifNoneMatch: "*" }); // we created it } catch (err) { if (isNativePreconditionFailed(err)) { // someone else created it first } else throw err; } ``` ```go [Go] _, err := native.PutObject(ctx, lockwellnative.PutObjectInput{ Bucket: "reports", Key: "q1/summary.txt", Body: body, IfNoneMatch: "*", }) if lockwellnative.IsPreconditionFailed(err) { // someone else created it first } else if err != nil { return err } ``` ```java [Java] import com.lockwell.sdk.nativeapi.NativeException; import com.lockwell.sdk.nativeapi.NativeTypes.PutOptions; try { nativeClient.putObject("reports", "q1/summary.txt", body, new PutOptions().ifAbsent()); // If-None-Match: * } catch (NativeException e) { if (e.statusCode() == 412) { // someone else created it first } else throw e; } ``` ::: ## Overwrite-only: `If-Match: ""` `If-Match: ""` writes only when the current object's ETag **matches** the value you pass. If the object changed since you last read it (its ETag differs), the write fails with `412` and your update is rejected. This is optimistic concurrency: read, compute a new value from what you read, then write back conditioned on the ETag you started from. A mismatch means someone else wrote in between, so re-read and retry. This too is a native-client write. ::: code-group ```ts [Node] // Read, mutate, write-back guarded by the ETag we read: const cur = await native.getObject("config", "settings.json"); const next = patch(cur.body); try { await native.putObject("config", "settings.json", next, { ifMatch: cur.etag }); } catch (err) { if (isNativePreconditionFailed(err)) { // lost the race; re-read and retry } else throw err; } ``` ```go [Go] cur, err := native.GetObject(ctx, lockwellnative.GetObjectInput{Bucket: "config", Key: "settings.json"}) if err != nil { return err } etag := cur.ETag body, _ := io.ReadAll(cur) cur.Close() _, err = native.PutObject(ctx, lockwellnative.PutObjectInput{ Bucket: "config", Key: "settings.json", Body: bytes.NewReader(patch(body)), IfMatch: etag, }) if lockwellnative.IsPreconditionFailed(err) { // lost the race; re-read and retry } ``` ```java [Java] var cur = nativeClient.getObject("config", "settings.json"); byte[] body = cur.body().readAllBytes(); String etag = cur.etag(); cur.close(); nativeClient.putObject("config", "settings.json", patch(body), new PutOptions().ifMatch(etag)); // A 412 NativeException means the ETag moved; re-read and retry. ``` ::: The ETag is the object's content identity. `headObject` or any read returns it, and so does the `PutObject` result, so you can chain writes without an extra round-trip. ## Conditional copy (the S3 path to conditional writes) The S3 `PutObject` supports create-only `If-None-Match: *`; overwrite-only `If-Match` remains native-only. The S3 client also exposes copy-source conditionals. Native copy adds destination preconditions evaluated atomically at commit: * `requireAbsent` copies only when the destination is absent (create-only). * `requireMatchEtag` copies only when the destination's ETag matches (overwrite-only). :::: s3 ::: code-group ```ts [Node] // Create-only via copy: never clobber an existing snapshot. await native.copyObject("snapshots", "latest.json", { sourceBucket: "live", sourceKey: "state.json", requireAbsent: true, // 412 if snapshots/latest.json already exists }); ``` ```go [Go] _, err := native.CopyObject(ctx, lockwellnative.CopyObjectInput{ Bucket: "snapshots", Key: "latest.json", SourceBucket: "live", SourceKey: "state.json", RequireAbsent: true, // 412 if snapshots/latest.json already exists }) ``` ```java [Java] nativeClient.copyObject("snapshots", "latest.json", "live", "state.json", new CopyOptions().ifAbsent()); // 412 if snapshots/latest.json already exists ``` ::: :::: Copy-**source** conditionals (gate on the source instead of the destination) are on both clients. See [copying objects](/guide/copying-objects#copy-source-conditionals). ## Idempotency keys An idempotency key makes a write **replay-safe**: a retry that carries the same key and the same payload returns the original stored result instead of writing a second time. Reach for it when a network blip might make you re-send a `PutObject` you are not sure landed. It works on both clients, but the native client has one extra requirement. ### Native: pair the key with a body checksum The native `putObject` streams the body and never buffers it, so it cannot hash the payload to prove a replay is the same bytes. You supply that proof: pair the idempotency key with a `checksums` entry. The server verifies the digest **before committing any bytes**, and uses the key plus the verified digest to collapse duplicate writes. ::: warning A native idempotent `putObject` needs a body checksum. Without a `checksums` entry, the streaming write has no integrity proof and the server cannot confirm a retry is the same payload. ::: :::: native ::: code-group ```ts [Node] import { sha256ChecksumBase64 } from "@kelphect/sdk"; const body = "invoice-payload"; await native.putObject("billing", "invoices/2026-001.json", body, { idempotencyKey: "invoice-2026-001", checksums: { sha256: await sha256ChecksumBase64(body) }, }); ``` ```go [Go] import ( "crypto/sha256" "encoding/base64" ) sum := sha256.Sum256([]byte("invoice-payload")) _, err := native.PutObject(ctx, lockwellnative.PutObjectInput{ Bucket: "billing", Key: "invoices/2026-001.json", Body: strings.NewReader("invoice-payload"), IdempotencyKey: "invoice-2026-001", Checksums: map[string]string{"sha256": base64.StdEncoding.EncodeToString(sum[:])}, }) ``` ```java [Java] import java.security.MessageDigest; import java.util.Base64; byte[] body = "invoice-payload".getBytes(); String sha256 = Base64.getEncoder().encodeToString( MessageDigest.getInstance("SHA-256").digest(body)); nativeClient.putObject("billing", "invoices/2026-001.json", body, new PutOptions() .idempotencyKey("invoice-2026-001") .checksum("sha256", sha256)); ``` ::: :::: Supported algorithms include `sha256`, `sha1`, `crc32`, and `crc32c`. See [checksums](/guide/checksums) for the full set. ### S3: the buffered PutObject The S3 `PutObject` buffers the body and signs its exact SHA-256, so an idempotency key needs no extra checksum: the signed body hash is the integrity proof. Pass `WithIdempotencyKey` / `idempotencyKey` and the SDK signs the header so it cannot be stripped or altered in transit. :::: s3 ::: code-group ```ts [Node] await s3.putObject("billing", "invoices/2026-001.json", Buffer.from(body), { idempotencyKey: "invoice-2026-001", }); ``` ```go [Go] _, err := s3.PutObject(ctx, "billing", "invoices/2026-001.json", body, lockwellsdk.WithIdempotencyKey("invoice-2026-001"), ) ``` ```java [Java] s3.putObject("billing", "invoices/2026-001.json", body, new LockwellClient.PutOptions().idempotencyKey("invoice-2026-001")); ``` ::: :::: The S3 streaming upload (`putObjectStream`) does **not** accept an idempotency key: its trailing checksum is not known when the write is reserved. Use the buffered `PutObject` when you need idempotency, or [multipart upload](/guide/multipart-uploads) for a large object (complete-multipart accepts an idempotency key). ## How idempotency interacts with retries Every native client refreshes its token before expiry and re-mints once on a `401`. The Java native client also retries transient failures by default, but it only replays `PUT` or `POST` when the request carries an idempotency key. Carry an idempotency key (with its checksum) so any client-side retry is collapsed server-side. Retry tuning is on [Errors & retries](/guide/errors-and-retries). The S3 client's automatic retry policy is idempotency-aware. It always retries safe methods (GET, HEAD, DELETE), and it retries a `PutObject` / `POST` **only** when the request carries an idempotency key and has a re-readable buffered body. A streaming body is never auto-retried (the reader is already consumed). This is exactly why an idempotency key matters: it is what lets the client safely replay a write after a transport error or a `5xx`. :::: s3 ```go // Default policy: up to 3 attempts with backoff + jitter. A keyed PutObject is // eligible for automatic retry; without the key it is attempted once. s3, _ := lockwellsdk.New(endpoint, creds, lockwellsdk.WithRetryPolicy(lockwellsdk.DefaultRetryPolicy())) ``` :::: ## Choosing the right guard | You want to... | Use | | --------------------------------------------- | ------------------------------------------------------------------------------ | | Create a key only if it does not exist | native `putObject` with `ifNoneMatch: "*"`, or a copy with `requireAbsent` | | Overwrite only if unchanged since you read it | native `putObject` with `ifMatch: ""`, or a copy with `requireMatchEtag` | | Make a retried write replay safely | an idempotency key (native: pair with a checksum) | | Copy only if the source still matches | copy-source `ifMatch` / `ifNoneMatch` (both clients) | ## Next steps * [Upload & download](/guide/data-operations): the write these conditions gate. * [Copying objects](/guide/copying-objects): destination and source conditionals on copy. * [Checksums](/guide/checksums): the body-integrity digests native idempotency needs. * [Errors & retries](/guide/errors-and-retries): the retry policy that replays keyed writes. --- --- url: /guide/checksums.md description: >- Verify Lockwell object integrity end to end with CRC32, CRC32C, CRC64NVME, SHA1, and SHA256 digests on single, multipart, and edge-runtime writes. --- # Checksums and integrity Lockwell verifies object integrity end to end with five algorithms: `CRC32`, `CRC32C`, `CRC64NVME`, `SHA1`, and `SHA256`. The flow is the same for all of them: * The SDK computes the digest over the exact bytes you upload and sends it on the request. * The server recomputes it over the bytes it received and rejects a mismatch before anything is committed. * The verified digest is stored and echoed back, so later reads return it for a downstream consumer to re-check. Pick an algorithm by need. `CRC64NVME` is the modern default: fast and covering the full object. Use `SHA256` when you need a cryptographic digest, for an external attestation, a content-addressed lookup, or a regulatory requirement. The CRC variants are cheaper on the CPU and are the right pick for throughput. ## Put an object with a verified checksum The native client takes checksums as a map of algorithm name to a precomputed base64 digest. Compute the digest yourself (or with the helpers below) and pass it. The server verifies it before any bytes are committed, then echoes it back on the result. :::: native ::: code-group ```ts [Node] import { NativeClient, computeChecksumBase64 } from "@kelphect/sdk"; const native = new NativeClient({ endpoint: "https://objects.example.com", accessKeyId: process.env.LOCKWELL_ACCESS_KEY_ID, secretKey: process.env.LOCKWELL_SECRET_KEY, }); const body = "hello world"; const res = await native.putObject("reports", "q1/summary.txt", body, { checksums: { crc64nvme: computeChecksumBase64("CRC64NVME", body) }, }); console.log(res.checksums.crc64nvme); // the server-verified base64 digest ``` ```go [Go] import ( "crypto/sha256" "encoding/base64" "github.com/KelpHect/lockwell/pkg/lockwellnative" ) native, err := lockwellnative.New("https://objects.example.com", os.Getenv("LOCKWELL_ACCESS_KEY_ID"), os.Getenv("LOCKWELL_SECRET_KEY")) sum := sha256.Sum256([]byte("hello world")) res, err := native.PutObject(ctx, lockwellnative.PutObjectInput{ Bucket: "reports", Key: "q1/summary.txt", Body: strings.NewReader("hello world"), Checksums: map[string]string{"sha256": base64.StdEncoding.EncodeToString(sum[:])}, }) if err != nil { return err } fmt.Println(res.Checksums["sha256"]) // the server-verified base64 digest ``` ```java [Java] import com.lockwell.sdk.nativeapi.NativeTypes.PutOptions; import com.lockwell.sdk.Checksums; // reuse the S3 SDK's hashing helper byte[] body = "hello world".getBytes(); var res = nativeClient.putObject("reports", "q1/summary.txt", body, new PutOptions().checksum("crc64nvme", Checksums.computeBase64("CRC64NVME", body))); System.out.println(res.checksums().get("crc64nvme")); // the server-verified digest ``` ::: :::: :::: s3 The S3 client takes a checksum algorithm as a put option and hashes the buffered body for you, sending `x-amz-checksum-`: ::: code-group ```ts [Node] const res = await s3.putObject("reports", "q1/summary.txt", "hello world", { checksumAlgorithm: "CRC64NVME", }); console.log(res.checksums.crc64nvme); // the server-verified base64 digest ``` ```go [Go] res, err := s3.PutObject(ctx, "reports", "q1/summary.txt", []byte("hello world"), lockwellsdk.WithChecksumAlgorithm(lockwellsdk.ChecksumCRC64NVME)) if err != nil { return err } fmt.Println(res.Checksums.CRC64NVME) // the server-verified base64 digest ``` ```java [Java] import com.lockwell.sdk.LockwellClient.PutOptions; var res = s3.putObject("reports", "q1/summary.txt", "hello world".getBytes(), new PutOptions().checksum("CRC64NVME")); System.out.println(res.checksums().crc64nvme()); // the server-verified base64 digest ``` ::: :::: If the body is corrupted in transit, the server's recomputed digest will not match what was sent and the write fails with a `BadDigest` error (status 400). The object is never written. ## Compute a digest without uploading The SDKs export the hashing helpers, so you can precompute a digest (for a manifest, a dedup probe, or to compare against a returned value) without a network call. The output is byte-for-byte identical to what the server stores, and it feeds the native `checksums` map directly. ::: code-group ```ts [Node] import { computeChecksumBase64, checksumHeaderName, CHECKSUM_ALGORITHMS } from "@kelphect/sdk"; const digest = computeChecksumBase64("SHA256", "hello world"); const header = checksumHeaderName("SHA256"); // "x-amz-checksum-sha256" console.log(CHECKSUM_ALGORITHMS); // ['CRC32','CRC32C','CRC64NVME','SHA1','SHA256'] ``` ```java [Java] import com.lockwell.sdk.Checksums; String digest = Checksums.computeBase64("SHA256", "hello world".getBytes()); String header = Checksums.headerName("SHA256"); // "x-amz-checksum-sha256" String[] all = Checksums.ALGORITHMS; ``` ::: The Go SDK computes and sends the digest internally when you pass `WithChecksumAlgorithm` on the S3 client, so there is no exported helper to call by hand on that path. On the native path, compute the digest with the standard library (as in the Go example above) and pass it in the `Checksums` map. ## The Checksums response shape Native object reads surface the recorded digests as `X-Lockwell-Checksum-` headers, parsed into a `Checksums` map keyed by lowercase algorithm. Read the entry for the algorithm the object was written with. :::: native ::: code-group ```ts [Node] const got = await native.getObject("reports", "q1/summary.txt"); if (got.checksums.crc64nvme) { // re-verify downstream if you want to: hash got.body yourself and compare } ``` ```go [Go] got, err := native.GetObject(ctx, lockwellnative.GetObjectInput{Bucket: "reports", Key: "q1/summary.txt"}) defer got.Close() fmt.Println(got.Checksums["crc64nvme"]) // keyed by lowercase algorithm ``` ```java [Java] try (var got = nativeClient.getObject("reports", "q1/summary.txt")) { System.out.println(got.checksums().get("crc64nvme")); } ``` ::: :::: :::: s3 The S3 client returns a `Checksums` value with one field per algorithm. Only the field for the algorithm the object was written with is populated; the rest are empty strings. The value is base64-encoded, matching the `x-amz-checksum-` wire value. | Field (Go / Java) | Field (Node) | Wire header | | ----------------- | ------------ | -------------------------- | | `CRC32` | `crc32` | `x-amz-checksum-crc32` | | `CRC32C` | `crc32c` | `x-amz-checksum-crc32c` | | `CRC64NVME` | `crc64nvme` | `x-amz-checksum-crc64nvme` | | `SHA1` | `sha1` | `x-amz-checksum-sha1` | | `SHA256` | `sha256` | `x-amz-checksum-sha256` | `PutObject`, `GetObject`, `HeadObject`, `UploadPart`, and `CompleteMultipartUpload` all carry a `Checksums` value on the S3 client. ```ts [Node] const got = await s3.getObject("reports", "q1/summary.txt"); if (got.checksums.crc64nvme) { // re-verify downstream if you want to } ``` :::: ## Per-part checksums on multipart uploads A multipart upload checksums each part on the way up, then folds the parts into one composite checksum on the assembled object. On the native path, supply the per-part digest in the `checksums` map on every `uploadPart`, and read the composite off the object after completion. :::: native ::: code-group ```ts [Node] const mpu = await native.createMultipartUpload("reports", "big.bin", { contentType: "application/octet-stream", }); const parts = []; for (let n = 1; n <= chunks.length; n++) { const p = await native.uploadPart("reports", "big.bin", mpu.uploadId, n, chunks[n - 1], { checksums: { crc32c: computeChecksumBase64("CRC32C", chunks[n - 1]) }, }); parts.push({ partNumber: n, etag: p.etag }); } const done = await native.completeMultipartUpload("reports", "big.bin", mpu.uploadId, parts); const head = await native.headObject("reports", "big.bin"); console.log(head.checksums.crc32c); // the composite checksum of the whole object ``` ```go [Go] mpu, err := native.CreateMultipartUpload(ctx, "reports", "big.bin") var parts []lockwellnative.CompleteMultipartPart for n, chunk := range chunks { // each part carries its own X-Lockwell-Checksum- digest server-side uploaded, err := native.UploadPart(ctx, lockwellnative.UploadPartInput{ Bucket: "reports", Key: "big.bin", UploadID: mpu.UploadID, PartNumber: n + 1, Body: bytes.NewReader(chunk), }) if err != nil { return err } parts = append(parts, lockwellnative.CompleteMultipartPart{PartNumber: uploaded.PartNumber, ETag: uploaded.ETag}) } _, err = native.CompleteMultipartUpload(ctx, lockwellnative.CompleteMultipartInput{ Bucket: "reports", Key: "big.bin", UploadID: mpu.UploadID, Parts: parts, }) head, err := native.HeadObject(ctx, lockwellnative.GetObjectInput{Bucket: "reports", Key: "big.bin"}) fmt.Println(head.Checksums["crc32c"]) // composite over all parts ``` ```java [Java] var mpu = nativeClient.createMultipartUpload("reports", "big.bin", "application/octet-stream"); var etags = new java.util.ArrayList(); for (int n = 0; n < chunks.size(); n++) { var p = nativeClient.uploadPart("reports", "big.bin", mpu.uploadId(), n + 1, chunks.get(n)); etags.add(p.etag()); } nativeClient.completeMultipartUpload("reports", "big.bin", mpu.uploadId()); var head = nativeClient.headObject("reports", "big.bin"); System.out.println(head.checksums().get("crc32c")); // composite checksum ``` ::: :::: :::: s3 The S3 client declares the algorithm at `CreateMultipartUpload`, echoes it back, and you pass the echoed algorithm to every `UploadPart`. The composite lands on `CompleteMultipartUpload`: ::: code-group ```ts [Node] const mpu = await s3.createMultipartUpload("reports", "big.bin", { checksumAlgorithm: "CRC32C", }); const parts = []; for (let n = 1; n <= chunks.length; n++) { const p = await s3.uploadPart("reports", "big.bin", mpu.uploadId, n, chunks[n - 1], { checksumAlgorithm: mpu.checksumAlgorithm, // each part carries a verified digest }); parts.push({ partNumber: n, etag: p.etag }); } const done = await s3.completeMultipartUpload("reports", "big.bin", mpu.uploadId, parts); console.log(done.checksums.crc32c); // the composite checksum of the whole object ``` ```go [Go] mpu, err := s3.CreateMultipartUpload(ctx, "reports", "big.bin", lockwellsdk.WithChecksumAlgorithm(lockwellsdk.ChecksumCRC32C)) var parts []lockwellsdk.CompletedPart for n, chunk := range chunks { p, err := s3.UploadPart(ctx, "reports", "big.bin", mpu.UploadID, n+1, chunk, lockwellsdk.WithPartChecksum(mpu.ChecksumAlgorithm)) if err != nil { return err } parts = append(parts, lockwellsdk.CompletedPart{PartNumber: n + 1, ETag: p.ETag}) } done, err := s3.CompleteMultipartUpload(ctx, "reports", "big.bin", mpu.UploadID, parts) fmt.Println(done.Checksums.CRC32C) // the composite checksum of the whole object ``` ```java [Java] var mpu = s3.createMultipartUpload("reports", "big.bin", "application/octet-stream", "CRC32C"); var etags = new java.util.ArrayList(); for (int n = 0; n < chunks.size(); n++) { var p = s3.uploadPart("reports", "big.bin", mpu.uploadId(), n + 1, chunks.get(n), mpu.checksumAlgorithm()); // verified per-part digest etags.add(p.etag()); } var done = s3.completeMultipartUpload("reports", "big.bin", mpu.uploadId(), null, etags, null); System.out.println(done.checksums().crc32c()); // the composite checksum ``` ::: :::: The per-part digest is sent as a direct `x-amz-checksum-` header (S3) or `X-Lockwell-Checksum-` header (native) on each part, so each part is validated end to end and folded into the composite. See [multipart uploads](/guide/multipart-uploads) for the full lifecycle. ## The WebCrypto SHA-256 helper for edge runtimes The Node S3 client and the hashing helpers in `checksum.js` import `node:crypto`, so they do not bundle for Cloudflare Workers, Vercel Edge, Bun, or Deno. The edge-safe entry point exports a single helper, `sha256ChecksumBase64`, built on the web-standard `crypto.subtle` so it runs with no Node dependency. It pairs with the native client, which is edge-compatible. ::: tip On the edge, reach for `@kelphect/sdk/edge` and the native client. The S3 client and the full hashing helpers depend on `node:crypto` and will not bundle there. ::: ```ts [Node, edge runtime] import { NativeClient, sha256ChecksumBase64 } from "@kelphect/sdk/edge"; const native = new NativeClient({ endpoint: env.LOCKWELL_ENDPOINT, accessKeyId: env.LOCKWELL_ACCESS_KEY_ID, secretKey: env.LOCKWELL_SECRET_KEY, }); const body = "hello from the edge"; await native.putObject("reports", "edge.txt", body, { checksums: { sha256: await sha256ChecksumBase64(body) }, }); ``` `sha256ChecksumBase64` accepts a `string`, `Uint8Array`, or `ArrayBuffer` and returns the base64 digest as a promise. It covers the common SHA-256 case on the web path. For `CRC32C` or `CRC64NVME` on the edge, precompute the digest and pass it as a string. See [edge runtimes](/guide/edge-runtimes) for what else is edge-safe. ## When to use which | Need | Algorithm | | ---------------------------------------------------------------------- | ----------- | | Default, fast, full-object integrity | `CRC64NVME` | | Cheapest CRC, broad tooling parity | `CRC32` | | CRC with hardware acceleration on most CPUs | `CRC32C` | | Cryptographic digest (attestation, content addressing, regulated data) | `SHA256` | | Legacy `SHA1` parity with an external system | `SHA1` | Checksums pair naturally with idempotency on the native path: a streaming PUT is never buffered, so the server uses a supplied checksum to confirm a retried write is the same payload. See [conditional writes and idempotency](/guide/conditional-writes). ## Related * [Multipart uploads](/guide/multipart-uploads) for per-part and composite checksums. * [Errors and retries](/guide/errors-and-retries) for the `BadDigest` failure path. * [S3 operations reference](/reference/s3-operations) for the full operation matrix. --- --- url: /guide/object-tags.md description: >- Attach up to 10 key/value tags to a Lockwell object, get/set/delete the replace-on-write tag set, and tag a specific version in a versioned bucket. --- # Object tags Object tags are a small set of key/value labels attached to an object. Use them to mark lifecycle state (`status=final`), ownership (`team=finance`), or anything you want to filter or report on later. Tags are a replace-on-write set: writing tags replaces the whole set, it does not merge into the existing one. The tag set is independent of user metadata. Metadata is fixed at write time and travels with the object body. Tags can change after the object exists without rewriting the object. ::: warning Setting tags replaces the entire set. To add one tag, read the current set, modify it, then write it back; a bare set call drops every existing tag. ::: ## Limits and format Tags follow the S3 tagging rules: * Up to 10 tags per object. * A key is up to 128 characters; a value is up to 256 characters. * Keys and values are UTF-8 strings. * Keys are unique within the set (a repeated key keeps the last value). The server validates the set on write and rejects an oversized or malformed set. The native path applies the same limits as the S3 path. ## Get, Set, Delete The native client uses `getObjectTags`, `setObjectTags`, and `deleteObjectTags`. `setObjectTags` replaces the whole set and returns the stored set so you can confirm what landed. The Node native client takes and returns a plain object; the Go and Java native clients use a list of `Tag` records (`{key, value}`). :::: native ::: code-group ```ts [Node] await native.setObjectTags("reports", "q1/summary.txt", { team: "finance", status: "final", }); const tags = await native.getObjectTags("reports", "q1/summary.txt"); console.log(tags); // { team: 'finance', status: 'final' } await native.deleteObjectTags("reports", "q1/summary.txt"); ``` ```go [Go] _, err := native.SetObjectTags(ctx, "reports", "q1/summary.txt", []lockwellnative.Tag{ {Key: "team", Value: "finance"}, {Key: "status", Value: "final"}, }) tags, err := native.GetObjectTags(ctx, "reports", "q1/summary.txt") for _, t := range tags { fmt.Println(t.Key, t.Value) } err = native.DeleteObjectTags(ctx, "reports", "q1/summary.txt") ``` ```java [Java] import com.lockwell.sdk.nativeapi.NativeTypes.Tag; import java.util.List; nativeClient.setObjectTags("reports", "q1/summary.txt", List.of(new Tag("team", "finance"), new Tag("status", "final"))); List tags = nativeClient.getObjectTags("reports", "q1/summary.txt"); nativeClient.deleteObjectTags("reports", "q1/summary.txt"); ``` ::: :::: :::: s3 The S3 client exposes the same three operations under the S3 names: `PutObjectTagging` replaces the set, `GetObjectTagging` reads it, and `DeleteObjectTagging` clears it. ::: code-group ```ts [Node] await s3.putObjectTagging("reports", "q1/summary.txt", { team: "finance", status: "final", }); const tags = await s3.getObjectTagging("reports", "q1/summary.txt"); console.log(tags.team, tags.status); // "finance" "final" await s3.deleteObjectTagging("reports", "q1/summary.txt"); ``` ```go [Go] err := s3.PutObjectTagging(ctx, "reports", "q1/summary.txt", map[string]string{ "team": "finance", "status": "final", }) tags, err := s3.GetObjectTagging(ctx, "reports", "q1/summary.txt") fmt.Println(tags["team"], tags["status"]) // "finance" "final" err = s3.DeleteObjectTagging(ctx, "reports", "q1/summary.txt") ``` ```java [Java] import java.util.Map; s3.putObjectTagging("reports", "q1/summary.txt", Map.of("team", "finance", "status", "final")); Map tags = s3.getObjectTagging("reports", "q1/summary.txt"); System.out.println(tags.get("team") + " " + tags.get("status")); s3.deleteObjectTagging("reports", "q1/summary.txt"); ``` ::: :::: Both clients always replace the whole set. To add one tag to an existing set, read the set, modify it, and write it back. ::: code-group ```ts [Node] const current = await native.getObjectTags("reports", "q1/summary.txt"); await native.setObjectTags("reports", "q1/summary.txt", { ...current, reviewed: "yes" }); ``` ::: ## Tagging a specific version In a versioned bucket, tags belong to a version. The Node native client takes an optional `versionId` on its tagging methods; without it, the current version is tagged. The Go and Java native tagging methods operate on the current version only, so to read or replace the tags of a specific version from those languages, use the S3 client's `WithVersionID` (shown in the S3 block below). :::: native ::: code-group ```ts [Node] // Read the tags of a specific version: const tags = await native.getObjectTags("reports", "q1/summary.txt", { versionId }); // Replace the tags on a specific version: await native.setObjectTags("reports", "q1/summary.txt", { status: "archived" }, { versionId }); ``` ```go [Go] // The native tagging methods operate on the current version; reach for a versioned // tag set with the S3 client's WithVersionID, shown in the S3 block below. tags, err := native.GetObjectTags(ctx, "reports", "q1/summary.txt") ``` ```java [Java] List tags = nativeClient.getObjectTags("reports", "q1/summary.txt"); ``` ::: :::: :::: s3 The Go S3 client's tagging methods accept `WithVersionID` to target a non-current version: ::: code-group ```go [Go] // Read the tags of a specific version: tags, err := s3.GetObjectTagging(ctx, "reports", "q1/summary.txt", lockwellsdk.WithVersionID(versionID)) // Replace the tags on a specific version: err = s3.PutObjectTagging(ctx, "reports", "q1/summary.txt", map[string]string{"status": "archived"}, lockwellsdk.WithVersionID(versionID)) ``` ::: :::: The Node native client (shown above) is the cleanest way to tag a specific older version from a non-Go language, since the Node and Java S3 tagging methods operate on the current version only. See [versioning](/guide/versioning) for how versions and version ids work. ## Related * [Versioning](/guide/versioning) for tagging a specific version. * [Data operations](/guide/data-operations) for user metadata, the write-time alternative to tags. * [S3 operations reference](/reference/s3-operations) for the full operation matrix. --- --- url: /guide/versioning.md description: >- Keep every write of a key with Lockwell versioning, list versions and delete markers, restore a deleted object, and handle suspended-state null versions. --- # Versioning Versioning keeps every write of a key instead of overwriting in place. With versioning enabled, an overwrite creates a new version (the previous one stays recoverable), and a delete writes a delete marker (the object reads as gone, but the prior versions are still there). This is how you protect against accidental overwrites and deletes, and how object lock holds an immutable history. A bucket is in one of three versioning states: | State | Behavior | | ------------------ | ---------------------------------------------------------------------- | | Disabled (default) | One version per key; overwrites and deletes are permanent. | | Enabled | Every overwrite is a new version; every delete writes a delete marker. | | Suspended | New writes get the null version id; existing versions are preserved. | Versioning can be enabled, then suspended, then enabled again. Suspending never deletes existing versions. ## Set and read the versioning state Enable versioning before you need it: a version history only exists for writes made while versioning was enabled. The native client sets the state with `setBucketVersioning` and reads it with `getBucketVersioning`. :::: native ::: code-group ```ts [Node] await native.setBucketVersioning("reports", "Enabled"); const status = await native.getBucketVersioning("reports"); // 'Enabled' | 'Suspended' | '' ``` ```go [Go] // The Go native client uses the lowercase "enabled" / "suspended" spelling: _, err := native.SetBucketVersioning(ctx, "reports", "enabled") state, err := native.GetBucketVersioning(ctx, "reports") fmt.Println(state.Status) // "enabled" / "suspended" / "disabled" ``` ```java [Java] nativeClient.setBucketVersioning("reports", "enabled"); var state = nativeClient.getBucketVersioning("reports"); System.out.println(state.status()); // "enabled" / "suspended" / "disabled" ``` ::: :::: :::: s3 The S3 client takes `Enabled` or `Suspended` (there is no `Disabled` value to set; a bucket that was never enabled reads back as an empty string): ::: code-group ```ts [Node] await s3.putBucketVersioning("reports", "Enabled"); const status = await s3.getBucketVersioning("reports"); console.log(status); // "Enabled" (or "" when never enabled) ``` ```go [Go] err := s3.PutBucketVersioning(ctx, "reports", lockwellsdk.VersioningEnabled) status, err := s3.GetBucketVersioning(ctx, "reports") fmt.Println(status) // "Enabled" (or "" when never enabled) ``` ```java [Java] s3.putBucketVersioning("reports", "Enabled"); String status = s3.getBucketVersioning("reports"); // "Enabled" or "" ``` ::: :::: Note the spelling difference: the native client takes `enabled` / `suspended` (the lowercase wire form) while the S3 client takes the capitalized `Enabled` / `Suspended`. ::: warning The two clients spell the versioning state differently. The native client uses lowercase `enabled` / `suspended`; the S3 client uses capitalized `Enabled` / `Suspended`. ::: ## Versions and delete markers Once versioning is enabled, each PUT to the same key stacks a new version. The latest version is what a plain GET returns. :::: native ::: code-group ```ts [Node] await native.setBucketVersioning("docs", "Enabled"); const v1 = await native.putObject("docs", "readme.txt", "first"); const v2 = await native.putObject("docs", "readme.txt", "second"); console.log(v1.versionId, v2.versionId); // two distinct ids const latest = await native.getObject("docs", "readme.txt"); console.log(latest.body.toString()); // "second" ``` ::: :::: :::: s3 ```ts [Node] await s3.putBucketVersioning("docs", "Enabled"); const v1 = await s3.putObject("docs", "readme.txt", "first"); const v2 = await s3.putObject("docs", "readme.txt", "second"); console.log(v1.versionId, v2.versionId); // two distinct ids const latest = await s3.getObject("docs", "readme.txt"); console.log(latest.body.toString()); // "second" ``` :::: A delete in a versioned bucket does not remove the data. It writes a delete marker as the new latest version, so a plain GET returns a not-found while the prior versions remain. :::: native ::: code-group ```ts [Node] await native.deleteObject("docs", "readme.txt"); // writes a delete marker // A plain read now reports not-found: try { await native.getObject("docs", "readme.txt"); } catch (err) { // isNativeNotFound(err) === true } // The prior versions are still readable by version id (see below). ``` ::: :::: :::: s3 ```ts [Node] await s3.deleteObject("docs", "readme.txt"); // writes a delete marker try { await s3.getObject("docs", "readme.txt"); } catch (err) { // isNotFound(err) === true } ``` :::: To restore, delete the delete marker by its version id, which makes the previous version the latest again. ## List versions and delete markers `listObjectVersions` returns both real versions and delete markers in one listing, each carrying its `versionId`, `isLatest` flag, and (for versions) size and ETag. It is paginated by a `(keyMarker, versionIdMarker)` pair. On the native path, delete markers are folded into the `versions` list and flagged by a `deleteMarker` field on each entry (Node also surfaces a separate `deleteMarkers` array). :::: native ::: code-group ```ts [Node] const page = await native.listObjectVersions("docs", { prefix: "readme", maxKeys: 100 }); for (const v of page.versions) { console.log("version", v.key, v.versionId, v.isLatest, v.deleteMarker, v.size); } ``` ```go [Go] page, err := native.ListObjectVersions(ctx, lockwellnative.ListObjectVersionsInput{ Bucket: "docs", Prefix: "readme", MaxKeys: 100, }) for _, v := range page.Versions { fmt.Println(v.Key, v.VersionID, v.IsLatest, v.DeleteMarker, v.Size) } ``` ```java [Java] import com.lockwell.sdk.nativeapi.NativeTypes.ListVersionsOptions; var page = nativeClient.listObjectVersions("docs", new ListVersionsOptions("readme", null, null, 100)); page.versions().forEach(v -> System.out.println(v.key() + " " + v.versionId() + " latest=" + v.isLatest() + " marker=" + v.deleteMarker())); ``` ::: :::: :::: s3 The S3 `ListObjectVersions` splits the two into a `versions` list and a `deleteMarkers` list: ::: code-group ```ts [Node] const page = await s3.listObjectVersions("docs", { prefix: "readme", maxKeys: 100 }); for (const v of page.versions) { console.log("version", v.key, v.versionId, v.isLatest, v.size); } for (const m of page.deleteMarkers) { console.log("delete-marker", m.key, m.versionId, m.isLatest); } ``` ```go [Go] page, err := s3.ListObjectVersions(ctx, "docs", lockwellsdk.WithVersionsPrefix("readme"), lockwellsdk.WithVersionsMaxKeys(100)) for _, v := range page.Versions { fmt.Println("version", v.Key, v.VersionID, v.IsLatest, v.Size) } for _, m := range page.DeleteMarkers { fmt.Println("delete-marker", m.Key, m.VersionID, m.IsLatest) } ``` ```java [Java] import com.lockwell.sdk.LockwellClient.ListVersionsOptions; var page = s3.listObjectVersions("docs", new ListVersionsOptions().prefix("readme").maxKeys(100)); page.versions().forEach(v -> System.out.println("version " + v.key() + " " + v.versionId() + " latest=" + v.isLatest())); page.deleteMarkers().forEach(m -> System.out.println("delete-marker " + m.key() + " " + m.versionId())); ``` ::: :::: ### Paginate every version A version-id marker requires a key marker (both clients reject one without the other). On the native path, the Node client exposes a `paginateObjectVersions` async iterator that threads both markers for you; in Go and Java, follow `IsTruncated` with `NextKeyMarker` and `NextVersionIDMarker`. :::: native ::: code-group ```ts [Node] for await (const page of native.paginateObjectVersions("docs", { prefix: "readme" })) { for (const v of page.versions) console.log(v.key, v.versionId); } ``` ```go [Go] var keyMarker, versionMarker string for { page, err := native.ListObjectVersions(ctx, lockwellnative.ListObjectVersionsInput{ Bucket: "docs", Prefix: "readme", KeyMarker: keyMarker, VersionIDMarker: versionMarker, }) if err != nil { return err } for _, v := range page.Versions { fmt.Println(v.Key, v.VersionID) } if !page.IsTruncated { break } keyMarker, versionMarker = page.NextKeyMarker, page.NextVersionIDMarker } ``` ```java [Java] String keyMarker = null, versionMarker = null; for (;;) { var page = nativeClient.listObjectVersions("docs", new ListVersionsOptions("readme", keyMarker, versionMarker, null)); page.versions().forEach(v -> System.out.println(v.key() + " " + v.versionId())); if (!page.isTruncated()) break; keyMarker = page.nextKeyMarker(); versionMarker = page.nextVersionIdMarker(); } ``` ::: :::: :::: s3 The S3 client offers the same manual paging plus a built-in paginator: ::: code-group ```ts [Node] // Async iterator that threads both markers for you: for await (const page of s3.paginateObjectVersions("docs", { prefix: "readme" })) { for (const v of page.versions) console.log(v.key, v.versionId); } ``` ```go [Go] // Manual paging: var keyMarker, versionMarker string for { page, err := s3.ListObjectVersions(ctx, "docs", lockwellsdk.WithKeyMarker(keyMarker), lockwellsdk.WithVersionIDMarker(versionMarker)) if err != nil { return err } for _, v := range page.Versions { fmt.Println(v.Key, v.VersionID) } if !page.IsTruncated { break } keyMarker, versionMarker = page.NextKeyMarker, page.NextVersionIDMarker } ``` ```java [Java] var pager = s3.listObjectVersionsPaginator("docs", new ListVersionsOptions().prefix("readme")); while (pager.hasMorePages()) { var page = pager.nextPage(); page.versions().forEach(v -> System.out.println(v.key() + " " + v.versionId())); } ``` ::: :::: ## Read and delete a specific version Pass a version id to read or delete one exact version instead of the latest. The native client reads with the `versionId` option on `getObject` and deletes with `deleteObject(bucket, key, { versionId })`. :::: native ::: code-group ```ts [Node] // Read an older version: const old = await native.getObject("docs", "readme.txt", { versionId: v1.versionId }); console.log(old.body.toString()); // "first" // Permanently delete one version (not a delete marker; the bytes are gone): await native.deleteObject("docs", "readme.txt", { versionId: v1.versionId }); ``` ```go [Go] // Read an older version: old, err := native.GetObject(ctx, lockwellnative.GetObjectInput{ Bucket: "docs", Key: "readme.txt", VersionID: v1ID, }) defer old.Close() // Permanently delete one version: _, err = native.DeleteObject(ctx, "docs", "readme.txt", v1ID) ``` ```java [Java] // Read an older version (range null, versionId set): try (var old = nativeClient.getObject("docs", "readme.txt", null, v1Id)) { /* ... */ } // Permanently delete one version: nativeClient.deleteObject("docs", "readme.txt", v1Id); ``` ::: :::: :::: s3 ::: code-group ```ts [Node] // Read an older version: const old = await s3.getObject("docs", "readme.txt", { versionId: v1.versionId }); console.log(old.body.toString()); // "first" // Permanently delete one version (not a delete marker; the bytes are gone): await s3.deleteObject("docs", "readme.txt", { versionId: v1.versionId }); ``` ```go [Go] // Read an older version: old, err := s3.GetObject(ctx, "docs", "readme.txt", lockwellsdk.WithVersionID(v1ID)) defer old.Body.Close() // Permanently delete one version: err = s3.DeleteObject(ctx, "docs", "readme.txt", lockwellsdk.WithVersionID(v1ID)) ``` ```java [Java] import java.util.Map; // Read an older version (Range/versionId go through the query map): var old = s3.getObject("docs", "readme.txt", Map.of("versionId", v1Id)); ``` ::: :::: Deleting a specific version id removes that version's data for good. Deleting without a version id (in a versioned bucket) writes a delete marker instead. ## Suspended versioning and the null version Suspending versioning stops creating new versions. Writes made while suspended take the special null version id (`"null"`). A later write to the same key while still suspended overwrites that null version in place, so at most one null version exists per key. Existing non-null versions from the enabled period are untouched and stay readable by their version ids. :::: native ::: code-group ```ts [Node] await native.setBucketVersioning("docs", "Enabled"); const enabled = await native.putObject("docs", "config.json", '{"v":1}'); // real version id await native.setBucketVersioning("docs", "Suspended"); await native.putObject("docs", "config.json", '{"v":2}'); // takes the "null" version id await native.putObject("docs", "config.json", '{"v":3}'); // overwrites the same "null" version // The enabled-period version is still there: const v1 = await native.getObject("docs", "config.json", { versionId: enabled.versionId }); ``` ::: :::: :::: s3 ```ts [Node] await s3.putBucketVersioning("docs", "Enabled"); const enabled = await s3.putObject("docs", "config.json", '{"v":1}'); // real version id await s3.putBucketVersioning("docs", "Suspended"); await s3.putObject("docs", "config.json", '{"v":2}'); // takes the "null" version id await s3.putObject("docs", "config.json", '{"v":3}'); // overwrites the same "null" version const v1 = await s3.getObject("docs", "config.json", { versionId: enabled.versionId }); ``` :::: Re-enabling versioning resumes stacking new versions; it does not retroactively version the writes made while suspended. ## Versioning and object lock Object lock requires versioning, so a bucket created with object lock has versioning enabled and cannot return to a non-versioned state. Each version can carry its own retention deadline and legal hold. See [object lock](/guide/object-lock). ## Related * [Object lock](/guide/object-lock) for per-version WORM retention and legal holds. * [Deleting objects](/guide/deleting-objects) for delete markers and batch deletes. * [Object tags](/guide/object-tags) for tagging a specific version. * [S3 operations reference](/reference/s3-operations) for the full operation matrix. --- --- url: /guide/multipart-uploads.md description: >- Upload large objects in parts with Lockwell, covering the create-upload-complete lifecycle, per-part checksums, resuming, UploadPartCopy, and abort. --- # Multipart uploads A multipart upload splits a large object into parts that upload independently, then assembles them server-side into one object. Use it for big files (so a failed part retries without re-sending the whole object), for resumable uploads, and for uploading parts in parallel. The lifecycle is always the same: 1. Create the upload and get an `uploadId`. 2. Upload each part (1-based `partNumber`, 1 to 10000), keeping the ETag each part returns. 3. Complete the upload with the ordered list of part numbers and ETags. 4. If anything goes wrong, abort the upload to release the parts. Every part except the last must be at least 5 MiB; the last part can be any size. An upload that is never completed or aborted is swept by the lifecycle job, so a crashed client does not leak parts forever. ::: warning Every part except the last must be at least 5 MiB. A too-small non-final part is rejected at completion, so size your chunks before you start uploading. ::: ## A complete large-file upload The native client mirrors the lifecycle over JSON. Parts stream with no buffering, and the bearer token is refreshed proactively before each streaming part (a streaming body cannot be replayed). This reads a file in fixed-size chunks, uploads each as a part, then completes. On any failure it aborts so no orphaned parts remain. :::: native ::: code-group ```ts [Node] import { createReadStream } from "node:fs"; const bucket = "reports"; const key = "big.bin"; const mpu = await native.createMultipartUpload(bucket, key, { contentType: "application/octet-stream", }); try { const parts = []; let partNumber = 1; for (const chunk of chunks) { const p = await native.uploadPart(bucket, key, mpu.uploadId, partNumber, chunk); parts.push({ partNumber, etag: p.etag }); partNumber++; } const done = await native.completeMultipartUpload(bucket, key, mpu.uploadId, parts); console.log(done.etag, done.versionId); } catch (err) { await native.abortMultipartUpload(bucket, key, mpu.uploadId); throw err; } ``` ```go [Go] mpu, err := native.CreateMultipartUpload(ctx, "reports", "big.bin") if err != nil { return err } var parts []lockwellnative.CompleteMultipartPart for n, chunk := range chunks { uploaded, err := native.UploadPart(ctx, lockwellnative.UploadPartInput{ Bucket: "reports", Key: "big.bin", UploadID: mpu.UploadID, PartNumber: n + 1, Body: bytes.NewReader(chunk), }) if err != nil { _ = native.AbortMultipartUpload(ctx, "reports", "big.bin", mpu.UploadID) return err } parts = append(parts, lockwellnative.CompleteMultipartPart{PartNumber: uploaded.PartNumber, ETag: uploaded.ETag}) } done, err := native.CompleteMultipartUpload(ctx, lockwellnative.CompleteMultipartInput{ Bucket: "reports", Key: "big.bin", UploadID: mpu.UploadID, Parts: parts, }) if err != nil { return err } fmt.Println(done.ETag, done.VersionID) ``` ```java [Java] var mpu = nativeClient.createMultipartUpload("reports", "big.bin", "application/octet-stream"); try { var parts = new java.util.ArrayList(); for (int n = 0; n < chunks.size(); n++) { var uploaded = nativeClient.uploadPart("reports", "big.bin", mpu.uploadId(), n + 1, chunks.get(n)); parts.add(new com.lockwell.sdk.nativeapi.NativeTypes.CompleteMultipartPart(n + 1, uploaded.etag())); } var done = nativeClient.completeMultipartUpload("reports", "big.bin", mpu.uploadId(), parts); System.out.println(done.etag()); } catch (Exception e) { nativeClient.abortMultipartUpload("reports", "big.bin", mpu.uploadId()); throw e; } ``` ::: :::: All native clients send an explicit ordered `{partNumber, etag}` completion manifest. The server validates that every referenced part exists and its ETag matches, then assembles exactly the selected parts; duplicate, out-of-order, missing, or mismatched entries are rejected. :::: s3 The S3 client follows the same shape; the body goes up as an `aws-chunked` stream and `completeMultipartUpload` takes the ordered ETag list: ::: code-group ```ts [Node] import { createReadStream } from "node:fs"; const bucket = "reports"; const key = "big.bin"; const PART = 8 * 1024 * 1024; // 8 MiB const mpu = await s3.createMultipartUpload(bucket, key, { contentType: "application/octet-stream", }); try { const parts = []; let partNumber = 1; for await (const chunk of chunked(createReadStream(key), PART)) { const p = await s3.uploadPart(bucket, key, mpu.uploadId, partNumber, chunk); parts.push({ partNumber, etag: p.etag }); partNumber++; } const done = await s3.completeMultipartUpload(bucket, key, mpu.uploadId, parts); console.log(done.etag, done.versionId); } catch (err) { await s3.abortMultipartUpload(bucket, key, mpu.uploadId); throw err; } ``` ```go [Go] const part = 8 << 20 // 8 MiB f, err := os.Open("big.bin") if err != nil { return err } defer f.Close() mpu, err := s3.CreateMultipartUpload(ctx, "reports", "big.bin", lockwellsdk.WithContentType("application/octet-stream")) if err != nil { return err } var parts []lockwellsdk.CompletedPart buf := make([]byte, part) for n := 1; ; n++ { read, rerr := io.ReadFull(f, buf) if read > 0 { p, err := s3.UploadPart(ctx, "reports", "big.bin", mpu.UploadID, n, buf[:read]) if err != nil { _ = s3.AbortMultipartUpload(ctx, "reports", "big.bin", mpu.UploadID) return err } parts = append(parts, lockwellsdk.CompletedPart{PartNumber: n, ETag: p.ETag}) } if rerr == io.EOF || rerr == io.ErrUnexpectedEOF { break } if rerr != nil { _ = s3.AbortMultipartUpload(ctx, "reports", "big.bin", mpu.UploadID) return rerr } } done, err := s3.CompleteMultipartUpload(ctx, "reports", "big.bin", mpu.UploadID, parts) if err != nil { return err } fmt.Println(done.ETag, done.VersionID) ``` ```java [Java] int part = 8 * 1024 * 1024; // 8 MiB var mpu = s3.createMultipartUpload("reports", "big.bin", "application/octet-stream"); try (var in = new java.io.FileInputStream("big.bin")) { var etags = new java.util.ArrayList(); byte[] buf = new byte[part]; int read, n = 1; while ((read = in.readNBytes(buf, 0, buf.length)) > 0) { byte[] chunk = read == buf.length ? buf : java.util.Arrays.copyOf(buf, read); etags.add(s3.uploadPart("reports", "big.bin", mpu.uploadId(), n++, chunk)); } var done = s3.completeMultipartUpload("reports", "big.bin", mpu.uploadId(), null, etags); System.out.println(done.etag()); } catch (Exception e) { s3.abortMultipartUpload("reports", "big.bin", mpu.uploadId()); throw e; } ``` ::: The Java `completeMultipartUpload(bucket, key, uploadId, null, etags)` infers part numbers from the ETag list order (part 1 is the first ETag). The variant that returns a composite checksum takes explicit part numbers. :::: ## Conditional completion (native) The native `completeMultipartUpload` accepts `If-None-Match: *` / `If-Match: ""` to gate the assembled object atomically at the commit. This is the multipart equivalent of a conditional create or overwrite, applied at the moment the parts become one object. The S3 client does not gate completion this way. ```go done, err := native.CompleteMultipartUpload(ctx, lockwellnative.CompleteMultipartInput{ Bucket: "reports", Key: "big.bin", UploadID: mpu.UploadID, IfNoneMatch: "*", // assemble only if the key is still absent (412 otherwise) }) ``` ## Per-part checksums Supply a per-part digest on every `uploadPart` and read the server-computed composite off the assembled object. Each part is then verified end to end and folded into the composite. This is covered in full on the [checksums](/guide/checksums#per-part-checksums-on-multipart-uploads) page; in short, on the native path: ```ts [Node] import { computeChecksumBase64 } from "@kelphect/sdk"; const mpu = await native.createMultipartUpload("reports", "big.bin"); const p = await native.uploadPart("reports", "big.bin", mpu.uploadId, 1, chunk, { checksums: { crc32c: computeChecksumBase64("CRC32C", chunk) }, }); await native.completeMultipartUpload("reports", "big.bin", mpu.uploadId, [{ partNumber: 1, etag: p.etag }]); const head = await native.headObject("reports", "big.bin"); console.log(head.checksums.crc32c); // composite over all parts ``` ## List in-progress uploads `listMultipartUploads` shows every upload that has been created but not yet completed or aborted in a bucket. Use it to find and clean up stale uploads. :::: native ::: code-group ```ts [Node] const page = await native.listMultipartUploads("reports", { prefix: "big" }); for (const u of page.uploads) console.log(u.key, u.uploadId, u.initiated); // Or every upload across pages: for await (const page of native.paginateMultipartUploads("reports")) { for (const u of page.uploads) console.log(u.key, u.uploadId); } ``` ```go [Go] page, err := native.ListMultipartUploads(ctx, "reports") for _, u := range page.Uploads { fmt.Println(u.Key, u.UploadID, u.Initiated) } ``` ```java [Java] var page = nativeClient.listMultipartUploads("reports"); page.uploads().forEach(u -> System.out.println(u.key() + " " + u.uploadId())); ``` ::: :::: :::: s3 The S3 `ListMultipartUploads` pages by a `(keyMarker, uploadIdMarker)` pair: ::: code-group ```ts [Node] const page = await s3.listMultipartUploads("reports", { prefix: "big", maxUploads: 100 }); for (const u of page.uploads) console.log(u.key, u.uploadId, u.initiated); // Or every upload across pages: for await (const page of s3.paginateMultipartUploads("reports")) { for (const u of page.uploads) console.log(u.key, u.uploadId); } ``` ```go [Go] page, err := s3.ListMultipartUploads(ctx, "reports", lockwellsdk.WithUploadsPrefix("big"), lockwellsdk.WithMaxUploads(100)) for _, u := range page.Uploads { fmt.Println(u.Key, u.UploadID, u.Initiated) } ``` ```java [Java] import com.lockwell.sdk.LockwellClient.ListUploadsOptions; var pager = s3.listMultipartUploadsPaginator("reports", new ListUploadsOptions().prefix("big")); while (pager.hasMorePages()) { pager.nextPage().uploads().forEach(u -> System.out.println(u.key() + " " + u.uploadId())); } ``` ::: :::: ## List the parts of an upload `listParts` returns the parts uploaded so far for one in-progress upload, with each part's number, ETag, and size. Use it to resume an interrupted upload (skip the parts already present) or to rebuild the completion list. :::: native ::: code-group ```ts [Node] const page = await native.listParts("reports", "big.bin", mpu.uploadId); for (const p of page.parts) console.log(p.partNumber, p.etag, p.size); ``` ```go [Go] page, err := native.ListParts(ctx, "reports", "big.bin", mpu.UploadID) for _, p := range page.Parts { fmt.Println(p.PartNumber, p.ETag, p.Size) } ``` ```java [Java] var page = nativeClient.listParts("reports", "big.bin", mpu.uploadId()); page.parts().forEach(p -> System.out.println(p.partNumber() + " " + p.etag())); ``` ::: :::: :::: s3 The S3 `ListParts` pages by `partNumberMarker`: ::: code-group ```ts [Node] const page = await s3.listParts("reports", "big.bin", mpu.uploadId, { maxParts: 100 }); for (const p of page.parts) console.log(p.partNumber, p.etag, p.size); // Every part across pages: for await (const page of s3.paginateParts("reports", "big.bin", mpu.uploadId)) { for (const p of page.parts) console.log(p.partNumber, p.etag); } ``` ```go [Go] page, err := s3.ListParts(ctx, "reports", "big.bin", mpu.UploadID, lockwellsdk.WithMaxParts(100)) for _, p := range page.Parts { fmt.Println(p.PartNumber, p.ETag, p.Size) } ``` ```java [Java] import com.lockwell.sdk.LockwellClient.ListPartsOptions; var pager = s3.listPartsPaginator("reports", "big.bin", mpu.uploadId(), new ListPartsOptions().maxParts(100)); while (pager.hasMorePages()) { pager.nextPage().parts().forEach(p -> System.out.println(p.partNumber() + " " + p.etag())); } ``` ::: :::: ## UploadPartCopy: build a part from an existing object `UploadPartCopy` fills a part by server-side-copying bytes from an existing object instead of re-uploading them. Pass a byte range to copy a slice; pass an empty range to copy the whole source object. This is how you stitch or re-chunk objects without moving data through the client. Supply a source version id to copy from a specific version. This is an S3-client operation; on the native path, use [copyObject](/guide/copying-objects) for server-side copies. :::: s3 ::: code-group ```ts [Node] const cp = await s3.uploadPartCopy( "reports", "source.bin", "", // src bucket, key, version (''=current) "reports", "assembled.bin", mpu.uploadId, 1, // destination part number "bytes=0-5242879", // copy the first 5 MiB ('' = whole object) ); parts.push({ partNumber: 1, etag: cp.etag }); ``` ```go [Go] cp, err := s3.UploadPartCopy(ctx, "reports", "source.bin", "", // src bucket, key, version "reports", "assembled.bin", mpu.UploadID, 1, // destination part number "bytes=0-5242879") // "" copies the whole object parts = append(parts, lockwellsdk.CompletedPart{PartNumber: 1, ETag: cp.ETag}) ``` ```java [Java] String etag = s3.uploadPartCopy("reports", "assembled.bin", mpu.uploadId(), 1, "reports", "source.bin", null, "bytes=0-5242879"); ``` ::: :::: ## Complete or abort `completeMultipartUpload` assembles the parts in part-number order into the final object and returns its ETag and version id. `abortMultipartUpload` discards the upload and frees its parts. Always abort on failure; an upload left dangling holds its parts until the lifecycle sweep expires it. The native completion accepts an idempotency key so a retried completion replays the original result instead of failing or double-assembling (Node passes it as the `idempotencyKey` option). :::: native ```ts [Node] const done = await native.completeMultipartUpload("reports", "big.bin", mpu.uploadId, parts, { idempotencyKey: "assemble-big-bin-2026-001", }); // Or give up on the upload entirely: await native.abortMultipartUpload("reports", "big.bin", mpu.uploadId); ``` :::: :::: s3 ```ts [Node] const done = await s3.completeMultipartUpload("reports", "big.bin", mpu.uploadId, parts, { idempotencyKey: "assemble-big-bin-2026-001", }); await s3.abortMultipartUpload("reports", "big.bin", mpu.uploadId); ``` :::: ## Related * [Checksums](/guide/checksums#per-part-checksums-on-multipart-uploads) for per-part and composite verification. * [Conditional writes](/guide/conditional-writes) for the idempotency key and `If-None-Match` at completion. * [Errors and retries](/guide/errors-and-retries) for which calls retry automatically. * [S3 operations reference](/reference/s3-operations) for the full operation matrix. --- --- url: /guide/object-lock.md description: >- Apply write-once-read-many protection to Lockwell object versions with retention modes and legal holds, enforced with the same WORM invariants on the native and S3 paths. --- # Object lock Object lock is write-once-read-many (WORM) protection: a locked object version cannot be deleted or overwritten until its retention expires or its legal hold is cleared. It is how you meet compliance and legal-hold requirements where data must be provably immutable for a window of time. Object lock has two independent controls: * **Retention** has a mode and a retain-until date. Until that date, the version is protected. * **Legal hold** is a separate `ON`/`OFF` flag with no expiry. While it is on, the version is protected regardless of retention. A version is deletable only when no retention window is active and no legal hold is on. ## Retention modes | Mode | Behavior | | ------------ | ----------------------------------------------------------------------------------------------------------------------------------- | | `GOVERNANCE` | Protects the version; a sufficiently privileged caller could bypass it on AWS. Lockwell does not implement that bypass (see below). | | `COMPLIANCE` | Immutable until the retain-until date. No one can shorten or remove it, including the account root. | For both modes, an active retention deadline can be **extended** to a later date but **never shortened**. The server rejects a write that would move the deadline earlier or weaken the mode. ## Enable object lock at bucket creation Object lock can only be turned on when the bucket is created, and a bucket with object lock has versioning enabled (lock requires versioning). On the native client, pass `objectLock: true` (Node) or set `ObjectLockEnabled` (Go) on the create-bucket input. A bucket created without object lock cannot have it added later. :::: native ::: code-group ```ts [Node] await native.createBucket("vault", { objectLock: true, versioning: true }); ``` ```go [Go] _, err := native.CreateBucket(ctx, lockwellnative.CreateBucketInput{ Name: "vault", Versioning: "enabled", ObjectLockEnabled: true, }) ``` ```java [Java] import com.lockwell.sdk.nativeapi.NativeTypes.CreateBucketOptions; nativeClient.createBucket("vault", new CreateBucketOptions("enabled", true)); ``` ::: :::: For Java ERP integrations, `StorageProfiles.fiscalArchiveBucket()` returns this same versioning + Object Lock create shape, and `StorageProfiles.fiscalArchiveWrite(...)` pairs a no-overwrite, checksummed, idempotent write with an explicit `COMPLIANCE` retention spec. The ERP still supplies the retain-until date. :::: s3 The S3 client enables object lock at create and takes a default retention rule (a mode and a number of days). New objects inherit that default unless you set per-object retention on the write: ::: code-group ```ts [Node] // The Node S3 client enables object lock by passing the lock config at create: await s3.createBucket("vault"); // then set a default retention rule and per-object retention via PutObject (below). ``` ```go [Go] err := s3.CreateBucket(ctx, "vault", lockwellsdk.WithObjectLockEnabled(lockwellsdk.ObjectLockCompliance, 365)) ``` ```java [Java] // createBucket(name, defaultMode, defaultDays): s3.createBucket("vault", "COMPLIANCE", 365); ``` ::: :::: ## Set and read retention and legal hold The native client has dedicated set/get methods for retention and legal hold, applied after the object exists. They enforce the same WORM gate as the S3 path. Retention takes a mode (`GOVERNANCE` or `COMPLIANCE`) and an RFC3339 retain-until date; legal hold is a separate flag. :::: native ::: code-group ```ts [Node] await native.setObjectRetention("vault", "ledger.json", { mode: "COMPLIANCE", retainUntil: "2030-01-01T00:00:00Z", }); await native.setObjectLegalHold("vault", "ledger.json", true); const r = await native.getObjectRetention("vault", "ledger.json"); // { mode, retainUntil } const held = await native.getObjectLegalHold("vault", "ledger.json"); // boolean ``` ```go [Go] _, err := native.SetObjectRetention(ctx, "vault", "ledger.json", "COMPLIANCE", "2030-01-01T00:00:00Z") _, err = native.SetObjectLegalHold(ctx, "vault", "ledger.json", "ON") r, err := native.GetObjectRetention(ctx, "vault", "ledger.json") fmt.Println(r.Mode, r.RetainUntil) hold, err := native.GetObjectLegalHold(ctx, "vault", "ledger.json") fmt.Println(hold.Status) // "ON" or "OFF" ``` ```java [Java] nativeClient.setObjectRetention("vault", "ledger.json", "COMPLIANCE", "2030-01-01T00:00:00Z"); nativeClient.setObjectLegalHold("vault", "ledger.json", "ON"); var r = nativeClient.getObjectRetention("vault", "ledger.json"); // mode() + retainUntil() var hold = nativeClient.getObjectLegalHold("vault", "ledger.json"); // status() ``` ::: :::: The Go native `SetObjectLegalHold` / `GetObjectLegalHold` use the string `"ON"` / `"OFF"`; the Node native client takes and returns a boolean; the Java native client takes and returns the string status. Pass a `versionId` option (Node) to target a specific version; without it the current version is used. :::: s3 S3 clients can set a version's retention and legal hold at write time, as options on `PutObject`. Mode and retain-until must be supplied together, and the bucket must have Object Lock enabled. The Go S3 client also supports post-write updates with typed `SetObjectRetention` / `SetObjectLegalHold` methods (and `Put...` aliases named for the S3 operations); pass `WithVersionID` to mutate a non-current version. ::: code-group ```ts [Node] await s3.putObject("vault", "ledger.json", body, { objectLockMode: "COMPLIANCE", objectLockRetainUntil: "2030-01-01T00:00:00Z", // RFC3339 objectLockLegalHold: true, }); ``` ```go [Go] until := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) _, err := s3.PutObject(ctx, "vault", "ledger.json", body, lockwellsdk.WithObjectLockRetention(lockwellsdk.ObjectLockCompliance, until), lockwellsdk.WithObjectLockLegalHold(true)) ``` ```java [Java] var opts = new PutOptions() .objectLock("COMPLIANCE", "2030-01-01T00:00:00Z") // mode + RFC3339 retain-until .legalHold(true); s3.putObject("vault", "ledger.json", body, opts); ``` ::: The Go S3 client uses an `ObjectRetention` with a mode enum and `time.Time` for a post-write retention update; legal hold takes a boolean. Both mutation methods emit the standard S3 XML request shape and reject an invalid mode, zero date, or non-future retention date before transport. `GetObjectRetention` and `GetObjectLegalHold` read the current lock state. All four Go methods target a version through `WithVersionID`; without it, they use the current version. A version with no retention reports a not-found error from `GetObjectRetention`. ::: code-group ```ts [Node] const r = await s3.getObjectRetention("vault", "ledger.json"); console.log(r.mode, r.retainUntilDate); // "COMPLIANCE" "2030-01-01T00:00:00Z" const held = await s3.getObjectLegalHold("vault", "ledger.json"); // boolean ``` ```go [Go] until := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) err := s3.SetObjectRetention(ctx, "vault", "ledger.json", lockwellsdk.ObjectRetention{ Mode: lockwellsdk.ObjectLockCompliance, RetainUntilDate: until, }, lockwellsdk.WithVersionID(versionID)) err = s3.SetObjectLegalHold(ctx, "vault", "ledger.json", true, lockwellsdk.WithVersionID(versionID)) r, err := s3.GetObjectRetention(ctx, "vault", "ledger.json") if lockwellsdk.IsNotFound(err) { // no retention set on this version } fmt.Println(r.Mode, r.RetainUntilDate) held, err := s3.GetObjectLegalHold(ctx, "vault", "ledger.json") // bool ``` ```java [Java] var r = s3.getObjectRetention("vault", "ledger.json"); // mode() + retainUntilDate() boolean held = s3.getObjectLegalHold("vault", "ledger.json"); ``` ::: :::: ## What protection blocks While a version is under active retention or a legal hold: * A delete of that version is refused. S3 returns `403 AccessDenied`; the native API returns `412` with `retention_blocked` or `legal_hold_blocked`. In a versioned bucket, deleting the key without a version id still writes a delete marker (the protected version is untouched and recoverable). * An overwrite that would replace the protected version's data is refused. * Shortening the retain-until date or weakening the mode is refused; extending the date is allowed. A legal hold has no expiry. It stays until you explicitly set it `OFF` (a boolean `false` on the Node native client), even after retention expires. ## No governance bypass On AWS, a caller with the `s3:BypassGovernanceRetention` permission can delete a `GOVERNANCE`-mode object early by sending a bypass header. Lockwell does not implement that bypass. A `GOVERNANCE` retention is enforced the same way as `COMPLIANCE` until its date passes, on both the S3 and native paths. There is no header, scope, or admin call that removes an active retention early. This is a deliberate non-goal that keeps the WORM guarantee honest. ::: info The governance bypass is a deliberate non-goal. `GOVERNANCE` and `COMPLIANCE` are equally immutable here, so do not rely on object lock for a deletable-by-an-operator escape hatch. ::: If you need a deletable-by-an-operator escape hatch, do not rely on object lock for it. Object lock means the data is immutable for the window you set. ## Related * [Versioning](/guide/versioning) for the version history object lock protects. * [Deleting objects](/guide/deleting-objects) for how a delete behaves when a version is protected. * [Errors and retries](/guide/errors-and-retries) for the surface-specific error a blocked delete returns. * [S3 operations reference](/reference/s3-operations) for the full operation matrix. --- --- url: /guide/tenancy-and-auth.md description: >- How Lockwell isolates customers with tenants, scoped access keys, native bearer tokens, signed URLs, and RBAC admin tokens, and the fail-closed posture behind them. --- # Tenancy and auth Lockwell is multi-tenant by design. An app maps each of its customers (an org, a workspace, a project) to a **tenant**, and every tenant is isolated: separate buckets, separate keys, separate quota. This page covers the model (tenants, scoped keys, native bearer tokens, signed URLs, admin tokens) and the security posture that backs it. ## The model at a glance | Concept | What it is | Issued by | Used for | | --------------------------------- | ------------------------------------------------ | ----------------------------- | --------------------------------------------- | | **Tenant** | An isolated namespace (your "org") | Admin API / app kit | Containing one customer's buckets and objects | | **Access key** | `accessKeyId` + `secretKey`, scoped to a tenant | Admin API / app kit | S3 SigV4, and minting native tokens | | **Native bearer token** (`lwtk_`) | Short-lived token minted from an access key | The native client (auto) | The native JSON data plane | | **Native signed URL** | A method/resource/TTL-bound URL, no token needed | A native client | Browser-direct upload/download | | **Admin token** (`lwadm_`) | Bearer token with an RBAC role | `lockwell admin-token create` | The JSON Admin API | ## Tenants A tenant is the unit of isolation. `provisionTenant` treats an existing tenant as success, then mints a fresh scoped key. Use it for onboarding or deliberate key creation, and store the returned secret immediately. For ordinary sign-in requests, load the tenant's stored credentials instead of provisioning again. ::: code-group ```ts [Node] const { tenantId, created, key } = await kit.provisionTenant("acme", { name: "Acme Inc", defaultBucket: "inbox", }); ``` ```go [Go] res, err := kit.ProvisionTenant(ctx, "acme", lockwellkit.ProvisionTenantInput{ Name: "Acme Inc", DefaultBucket: "inbox", }) ``` ```java [Java] ProvisionResult res = kit.provisionTenant("acme", new LockwellKit.ProvisionOptions().tenantName("Acme Inc").defaultBucket("inbox")); ``` ::: Map your customer's id to a Lockwell tenant id (e.g. `org_acme` becomes tenant `acme`). A credential's tenant is derived server-side from the credential itself, never from a request path, so one customer's request can never reach another's data. See [isolation](#tenant-isolation-comes-from-the-credential) below. Tenants are disabled or deleted through the Admin API (reason required, retention and legal-hold gated). See the [Admin API reference](/reference/admin-api). ## Scoped access keys An access key is an `accessKeyId` + `secretKey` bound to a tenant. The **secret is returned exactly once** on create and on rotate. Lockwell stores only the encrypted secret and can never show it again, so persist it in your own datastore (encrypted) the moment you receive it. ::: warning The secret is shown once There is no read-back endpoint for a secret. If you lose it, rotate the key to mint a new one. Capture `accessKeyId` and `secretKey` from the create or rotate response before doing anything else. ::: Keys carry a **scope** in a small policy grammar: per-operation verbs plus optional bucket/prefix narrowing. ```text read,write,delete # full data-plane access for the tenant op=read:bucket=inbox,op=write:bucket=inbox,op=delete:bucket=inbox # the same, restricted to one bucket op=read:bucket=inbox:prefix=incoming/ # read-only, one bucket, one prefix ``` The four verbs are `read`, `write`, `delete`, and `admin`. The app kit's `provisionTenant` mints a **data** key (`read,write,delete`, optionally bucket-scoped). It never hands back a key carrying the `admin` verb, so a provisioned key cannot manage buckets or policy. (When you ask the kit to create a default bucket, it mints a transient `admin`-scoped key for that one bucket and revokes it immediately.) The Java kit adds a retry-safe ERP bootstrap path, `ensureTenantProvisioning`, for installers that cannot tolerate duplicate live keys after a retry. Set `.keyExternalRef("")`; the kit lists active key metadata and reuses a matching `externalRef + scopes + expiry` key without re-exposing the secret. If the secret was lost before the ERP stored it, rotate that returned key with an audit reason and persist the new one-time secret from the rotation response; omitted rotate scopes/expiry preserve the old key's values. Create/rotate/revoke key calls accept an audit reason, and the Admin API emits `X-Request-Id` while storing that request id as audit correlation. For ERP company/purpose layouts, keep an ERP-side mapping table with opaque `tenantPublicRef`, `installationRef`, and `lockwellTenantId` values. Java `ErpScopes` derives `companies///` prefixes, key `externalRef` values, and purpose-scoped access-key templates; bucket names and prefixes must not contain customer legal names. Store the access key id per purpose so support can rotate/revoke by purpose, then drop the ERP credential cache after rotation. Tests should assert the generated prefix on every object path so cross-company mistakes are visible. ### The scope grammar in detail A scope string is one of two forms: * **Verb list.** A comma-separated subset of `read`, `write`, `delete`, `admin` applied to the whole tenant. Example: `read,write`. * **Qualified form.** `op=:bucket=:prefix=` where `bucket` and `prefix` narrow the verb. `prefix` requires `bucket`. Use one clause per verb, separated by commas. Example: `op=read:bucket=uploads:prefix=tenants/acme/,op=write:bucket=uploads:prefix=tenants/acme/`. Which verb each operation needs: | Operation | Verb | | --------------------------------------------------------------- | -------- | | GET, HEAD, list objects/versions, get tags/retention/legal-hold | `read` | | PUT, copy, multipart, set tags, set retention/legal-hold | `write` | | delete object, batch delete, abort multipart | `delete` | | create/delete bucket, set versioning, set notifications | `admin` | The same scope is enforced on **every** surface. A read-only key cannot write over S3, the native API, or a signed URL. ### Create, rotate, revoke, expire The key lifecycle runs through the Admin API (or the app kit's underlying admin client). Every mutation accepts `dryRun` to preview without applying, and the destructive ones (`revokeKey`) take a required, audited `reason`. ::: code-group ```ts [Node] // Create a precisely-scoped key. The secret is returned once. const k = await admin.createKey("acme", { scopes: "op=read:bucket=uploads:prefix=incoming/,op=write:bucket=uploads:prefix=incoming/", expiresAt: "2026-12-31", // RFC-3339, YYYY-MM-DD, or "never" }); console.log(k.accessKeyId, k.secretKey); // store both now // Rotate: revoke this key and mint a replacement. Restate the scope you want // kept (an empty rotate resets it to the server default). const rotated = await admin.rotateKey("acme", k.accessKeyId, { scopes: "op=read:bucket=uploads:prefix=incoming/,op=write:bucket=uploads:prefix=incoming/", }); console.log(rotated.accessKeyId, rotated.secretKey, rotated.oldAccessKeyId); // Change expiry / scope later (admin UI or API). Then revoke when retired. await admin.revokeKey("acme", k.accessKeyId, { reason: "employee offboarded" }); ``` ```go [Go] nk, _, err := admin.CreateKey(ctx, "acme", lockwelladmin.CreateKeyInput{ Scopes: "op=read:bucket=uploads:prefix=incoming/,op=write:bucket=uploads:prefix=incoming/", ExpiresAt: "2026-12-31", }) // nk.AccessKeyID, nk.SecretKey. Store both now. rotated, _, err := admin.RotateKey(ctx, "acme", nk.AccessKeyID, lockwelladmin.RotateKeyInput{ Scopes: "op=read:bucket=uploads:prefix=incoming/,op=write:bucket=uploads:prefix=incoming/", }) // rotated.AccessKeyID + rotated.SecretKey are new; rotated.OldAccessKeyID is revoked. _, _, err = admin.RevokeKey(ctx, "acme", nk.AccessKeyID, lockwelladmin.RevokeKeyInput{Reason: "employee offboarded"}) ``` ```java [Java] NewKey k = admin.createKey("acme", new CreateKeyOptions(null, "op=read:bucket=uploads:prefix=incoming/,op=write:bucket=uploads:prefix=incoming/", "2026-12-31")); // k.key().accessKeyId(), k.secretKey(). Store both now. NewKey rotated = admin.rotateKey("acme", k.key().accessKeyId(), new RotateKeyOptions( "op=read:bucket=uploads:prefix=incoming/,op=write:bucket=uploads:prefix=incoming/", null)); // rotated.key().accessKeyId() + rotated.secretKey() are new; rotated revokes the old. admin.revokeKey("acme", k.key().accessKeyId(), "employee offboarded"); ``` ::: Notes on each: * **Create** returns the secret once. There is no read-back. * **Rotate** revokes the old key and mints a replacement, so the new `accessKeyId` and secret both change and the response reports `oldAccessKeyId`. The previous key stops authenticating at once. Restate the scope you want on the new key, since an empty rotate falls back to the server default. Store the new pair, then swap your app over to it. * **Expiry** is a wall-clock cutoff (`expiresAt`). After it passes, the key fails closed on every surface. An expired key cannot mint a native token, and any outstanding native token it minted fails closed too. * **Revoke** is immediate for an in-process admin mutation (the access-key cache is busted) and propagates within the cache TTL (about 5s) for an out-of-process CLI revoke. A revoked key fails closed everywhere. ## Native bearer tokens (`lwtk_`) The native data plane authenticates with a short-lived bearer token, minted from an access key. You never pass a token in, only the key. The native client manages the token for you. It mints on first use (`POST /api/v1/auth/token`), caches it until shortly before expiry, refreshes transparently, and re-mints once on a `401`. Minting is concurrency-safe (single-flight), so a burst of requests shares one in-flight mint instead of one per request. The token is stateless and signed: ```text lwtk_. ``` The payload carries `{accessKeyId, tenantId, accountId, scopes, iat, exp}`. The signing key is derived from the deployment's at-rest master key via **HKDF** under a dedicated label, so it is per-deployment and never configured separately. The HMAC is compared in **constant time**. Two consequences matter: * **The tenant comes from the token, not the request.** A native call's tenant is whatever the signed token says. There is no tenant in the URL path to spoof. * **Revocation is honored promptly.** After the (DB-free) signature and expiry check, the middleware re-resolves the underlying access key through the same cached lookup the SigV4 path uses. A revoked or expired key, or a disabled tenant, **fails closed with `401`** even though the token's signature is still valid. The token can never outlive the key it points at, nor out-scope it. You almost never construct a token by hand. Hand the client your access key and let it manage the token. The secret and the live token are never logged or rendered (`toString`/`inspect`/JSON all redact them). The token TTL is set by `security.native_api_token_ttl` (default 1h). ## Native signed URLs A signed URL grants a single, **method- and resource-bound**, time-limited operation with **no bearer token**. It is the way to hand a browser a direct upload or download. The URL carries its authorization in a `token` query parameter, is bound to one bucket+key and one method, and **can never exceed the minting key's scope** (the server re-checks scope and bucket policy both at mint time and at access time). A read-only key asked to sign a `PUT` is denied `403`. The S3 client presigns **GET, PUT, HEAD, and DELETE**. The native API signs **GET and PUT**, and its constrained signed `PUT` is the sanctioned browser-upload path: ::: code-group ```ts [Node] const up = await kit.signedUploadUrl(creds, "inbox", "photo.jpg", { ttlSeconds: 300, contentType: "image/jpeg" }); const down = await kit.signedDownloadUrl(creds, "inbox", "photo.jpg", { ttlSeconds: 300 }); ``` ```go [Go] up, _ := kit.SignedUploadURL(ctx, creds, "inbox", "photo.jpg", lockwellkit.SignedUploadURLInput{TTLSeconds: 300, ContentType: "image/jpeg"}) down, _ := kit.SignedDownloadURL(ctx, creds, "inbox", "photo.jpg", 300) ``` ```java [Java] BrowserSignedUrl up = kit.signedUploadUrl(creds, "inbox", "photo.jpg", Duration.ofMinutes(5), "image/jpeg"); BrowserSignedUrl down = kit.signedDownloadUrl(creds, "inbox", "photo.jpg", Duration.ofMinutes(5)); ``` ::: TTL is clamped server-side to the deployment's maximum (`security.max_presign_ttl`). See [Signed URLs](/guide/signed-urls) for the browser-side handling. ## Admin tokens (`lwadm_`) and RBAC The Admin API authenticates with an **admin API token**, distinct from access keys. It is a high-entropy bearer secret minted **offline** on the server host (there is no JSON route to mint the first token; the bootstrap is filesystem-trust only) and stored **hashed** (SHA-256), never in plaintext: ```sh lockwell admin-token create --role operator # cluster-wide operator lockwell admin-token create --role viewer --tenant acme # read-only, one tenant ``` Authorization composes the **RBAC role** with an optional **single-tenant scope**: | Role | Can | | ---------- | --------------------------------------------------------------- | | `owner` | Everything, including admin-token and admin-user management | | `operator` | Tenant/key/quota lifecycle and audit (no admin-user management) | | `viewer` | Read-only (list/get tenants, keys, quota, usage, audit) | A **tenant-scoped** token is forced to its own tenant server-side. A request targeting another tenant is a `403`, never a `404` existence leak. Bearer tokens are not sent automatically by browsers, so the Admin API is server-to-server and carries no CSRF flow. Every request, success and denial, is audited. ```ts import { AdminClient } from "@kelphect/sdk"; const admin = new AdminClient({ endpoint: "http://localhost:9001", // admin listener, not the public S3 port token: process.env.LOCKWELL_ADMIN_TOKEN, // Authorization: Bearer lwadm_... }); await admin.listTenants(); ``` Keep the admin token server-side only. Provisioning runs in your backend. The browser never sees an admin token or an access-key secret, only short-lived signed URLs. ## How an app maps customers to tenants A typical multi-tenant backend: 1. On customer onboarding, call `provisionTenant()` once. Store the returned `{ accessKeyId, secretKey }` against that customer, encrypted. 2. On each request, look up the customer's creds and get a per-tenant native client (`clientForTenant`). It is cached per `(tenant, creds)` and reuses one token manager. 3. For browser I/O, mint a short-lived signed URL and return only the URL. 4. The customer id in **your** URL paths is a lookup key into **your** store. It is never a cross-tenant vector on Lockwell, because the acting tenant is always derived from the stored creds' token. The runnable [Go service example](https://github.com/RusticStack/lockwell/tree/main/examples/go-service) models exactly this shape. See also [App kit](/guide/app-kit). ## Security posture ### Tenant isolation comes from the credential The tenant is always taken from the signed credential, never from the request path, so cross-tenant access is structurally impossible. Another tenant's bucket simply does not exist for your token (returned as `404`, never a leak). The same holds across all three surfaces. ### Always-on, fail-closed * **Encryption at rest is always on.** Every object, written through any surface, is encrypted, deduped, quota-checked, and retention-gated through the same object pipeline. * **Constant-time** comparisons guard token HMACs and webhook signatures. Secrets and live tokens are never logged or rendered. * **Fail-closed by default.** Anonymous and unauthenticated requests, and revoked or expired credentials, are denied (`401`). A delete blocked by retention or a legal hold returns `412`. A tenant over quota returns `507`. ### Deliberate non-goals To keep the trust surface small, Lockwell deliberately does not offer: * **No public or anonymous buckets.** There is no access without a token or a signed URL. * **No SSE-KMS.** Encryption at rest is server-managed by default (SSE-S3-style). The S3 wire API and all three first-party S3 clients expose typed SSE-C and copy-source SSE-C helpers. Native/admin APIs do not accept SSE-C keys. * **No IAM/STS.** There is no policy/role/temporary-credential service. Scoping is done with the key scope grammar above. * **Webhook-only notifications.** SNS/SQS/Lambda targets return `501`. See [Webhooks](/guide/webhooks). ## Next steps * [The three surfaces](/guide/the-three-surfaces). Where each credential applies. * [Signed URLs](/guide/signed-urls) and [Webhooks](/guide/webhooks). Browser-direct and event paths. * [Admin API reference](/reference/admin-api). The full tenant/key/quota/audit surface. --- --- url: /guide/app-kit.md description: >- LockwellKit composes the admin and native clients so an app can provision tenants, get per-tenant clients, configure browser CORS, sign browser URLs, and verify webhooks with almost no glue. --- # The app kit `LockwellKit` is the near-zero-glue way to build a whole multi-tenant storage layer on Lockwell. It composes the two first-party clients, the [Admin API](/guide/tenancy-and-auth) client (control plane) and the [native data-plane](/guide/data-operations) client, into the five jobs an app would otherwise hand-roll: 1. **`provisionTenant`** ensures a tenant exists and mints a fresh scoped data key for it. 2. **`clientForTenant`** is a cached, per-tenant native client that auto-manages its bearer token. 3. **`configureBucketCORS`** opens the browser policy needed for direct signed URL fetches without storing an admin key. 4. **`signedUploadUrl` / `signedDownloadUrl`** mint browser-usable signed URLs (see [signed URLs](/guide/signed-urls)). 5. **`verifyWebhook`** does constant-time HMAC verification of incoming deliveries (see [webhooks](/guide/webhooks)). The kit introduces no new wire surface and no new server behavior. Every call it makes goes through the same owner-approved admin and native JSON APIs the two clients already wrap. There is no second store and no custom glue layer. ```mermaid flowchart LR KIT[LockwellKit] KIT -->|provisionTenant| T[Tenant + scoped key] T -->|store the secret| DB[(Your database)] KIT -->|clientForTenant| NC[Per-tenant native client] NC -->|put / get / list| OBJ[(Objects)] KIT -->|configureBucketCORS| CORS[Browser CORS rules] KIT -->|signedUploadUrl / signedDownloadUrl| URL[Browser-direct signed URL] KIT -->|verifyWebhook| HOOK[Verified event delivery] ``` ## Construct a kit The kit needs the **admin listener** (with an admin token, for the control plane) and the **public listener** (the native data plane is mounted at `/api/v1` under it). Per-tenant credentials are supplied per call, never at construction. ::: code-group ```ts [Node] 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" }, }); ``` ```go [Go] import ( "github.com/KelpHect/lockwell/pkg/lockwelladmin" "github.com/KelpHect/lockwell/pkg/lockwellkit" ) admin, err := lockwelladmin.New("https://admin.example.com", os.Getenv("LOCKWELL_ADMIN_TOKEN")) if err != nil { log.Fatal(err) } kit, err := lockwellkit.New(admin, "https://objects.example.com") if err != nil { log.Fatal(err) } ``` ```java [Java] import com.lockwell.sdk.kit.LockwellKit; var kit = LockwellKit.builder() .adminEndpoint("https://admin.example.com") .adminToken(System.getenv("LOCKWELL_ADMIN_TOKEN")) .nativeEndpoint("https://objects.example.com") .build(); ``` ::: Pass a shared `httpClient` (Go/Java) or `fetch` (Node) and a `userAgent` to control the transport for every client the kit builds. See [client options](/guide/installation#client-options). ## 1. Provision a tenant `provisionTenant` ensures the tenant exists (a pre-existing tenant is not an error) and mints a **fresh scoped data key** for it. The key grants `read,write,delete` for the whole tenant by default, or narrowed to one bucket. The minted key is always a **data key**, and it never carries the `admin` verb. Because the key is fresh, retrying `provisionTenant` after a lost response can intentionally create another live key. The **secret is returned exactly once**. Lockwell stores only its encrypted form and can never recover it. Persist it securely in your own store immediately. Optionally pass a **default bucket**. Creating a bucket is an admin operation, which the returned data key deliberately does not carry. So the kit mints a transient admin-on-this-bucket key, creates the bucket with it, and revokes it immediately. The bucket-create capability never outlives the call. It is idempotent: an already-existing bucket is fine. If browser direct upload/download is part of onboarding, also pass `bucketCors` / `DefaultBucketCORS` / `.bucketCORS(...)`. The same transient provisioning key sets the bucket's CORS rules before it is revoked, so the long-lived tenant data key remains admin-free. ::: code-group ```ts [Node] const { tenantId, created, key, bucket } = await kit.provisionTenant("acme", { name: "Acme Inc", defaultBucket: "uploads", bucketCors: { rules: [ { allowedOrigins: ["https://app.example.com"], allowedMethods: ["GET", "HEAD", "PUT"], allowedHeaders: ["content-type"], exposeHeaders: ["ETag"], maxAgeSeconds: 600, }, ], }, }); // Persist key.accessKeyId + key.secretKey now. The secret is shown ONCE. await saveCreds(tenantId, { accessKeyId: key.accessKeyId, secretKey: key.secretKey }); ``` ```go [Go] res, err := kit.ProvisionTenant(ctx, "acme", lockwellkit.ProvisionTenantInput{ Name: "Acme Inc", DefaultBucket: "uploads", DefaultBucketCORS: &lockwellnative.CORSConfiguration{ Rules: []lockwellnative.CORSRule{{ AllowedOrigins: []string{"https://app.example.com"}, AllowedMethods: []string{"GET", "HEAD", "PUT"}, AllowedHeaders: []string{"content-type"}, ExposeHeaders: []string{"ETag"}, MaxAgeSeconds: 600, }}, }, }) if err != nil { return err } // Persist res.Creds now. res.Creds.SecretKey is shown ONCE. saveCreds(res.TenantID, res.Creds) ``` ```java [Java] import com.lockwell.sdk.kit.LockwellKit.ProvisionOptions; var p = kit.provisionTenant("acme", new ProvisionOptions().tenantName("Acme Inc").defaultBucket("uploads") .bucketCORS(new CORSConfiguration(List.of(new CORSRule( List.of("https://app.example.com"), List.of("GET", "HEAD", "PUT")))))); // Persist now. p.credentials().secretKey() is shown ONCE. saveCreds(p.tenantId(), p.credentials()); ``` ::: To restrict the key to a single bucket, set `bucketScope` (Node), `Bucket` (Go), or `bucketScope(...)` (Java). Java emits one qualified clause per verb, for example `op=read:bucket=,op=write:bucket=,op=delete:bucket=`. For a precisely-scoped key (a prefix, read-only, an expiry), reach the underlying admin client and call `createKey` directly. See [scoped access keys](/guide/tenancy-and-auth#scoped-access-keys). ### Java ERP-safe ensure provisioning The Java kit also exposes `ensureTenantProvisioning`, `ensureTenant`, `ensureBucket`, and `ensureKey` for ERP/on-prem bootstrap retries where duplicate long-lived keys are unacceptable. Pass a stable external reference with `.keyExternalRef(...)`; Lockwell records it on key metadata as `externalRef`, and the Java kit reuses a matching active key with the same `externalRef + scopes + expiry` instead of minting another one. ```java import com.lockwell.sdk.kit.KitTypes.EnsureProvisionResult; import com.lockwell.sdk.kit.LockwellKit.ProvisionOptions; EnsureProvisionResult ensured = kit.ensureTenantProvisioning("acme", new ProvisionOptions() .tenantName("Acme Inc") .keyExternalRef("ts-install-123:uploads") .bucketScope("uploads") .defaultBucket("uploads") .reason("ERP bootstrap")); if (ensured.key().created()) { saveCreds(ensured.tenantId(), ensured.key().credentials()); // secret shown ONCE } else { rememberAccessKeyId(ensured.tenantId(), ensured.key().key().accessKeyId()); } ``` When `created()` is false, `credentials()` is null because existing secrets are never re-exposed. If the ERP lost the one-time secret before storing it, call `kit.admin().rotateKey(...)` for the returned key with a `RotateKeyOptions` audit reason and store the new secret from the rotation response; omitted rotate scopes/expiry preserve the existing key's values. `.reason(...)` on the provisioning options is recorded in the key-create audit row; the Admin API also emits `X-Request-Id` and stores it as audit correlation. Concurrent first bootstrap attempts can still both observe "no key yet" before either create reaches the server; serialize that first ensure call with the ERP database lock/outbox until Lockwell adds a server-side idempotency key for key creation. ### Java ERP transaction, outbox, and reconciliation Lockwell calls are not part of the ERP database transaction. During the ERP tenant/company transaction, only derive the opaque refs (`tenantPublicRef`, `installationRef`, `lockwellTenantId`, company ref, purpose) and persist an ERP outbox record. Do not call `ensureTenantProvisioning`, create buckets, or mint keys until the ERP row has committed. The outbox worker is the only place that talks to Lockwell for onboarding. It should run these steps with one ERP database lock per tenant/company: call `ensureTenantProvisioning` with a stable `keyExternalRef`, ensure the default bucket/profile, create purpose keys with `ErpScopes`, store each access-key id, and store one-time secrets only on the nested key result's `created=true`/credentials-present path. Retries are safe when every key uses stable `externalRef + scopes + expiry`: `ensureTenant` accepts an existing tenant, `ensureBucket` accepts an existing bucket, and `ensureKey` reuses matching active keys instead of duplicating live credentials. If the ERP lost a one-time secret, rotate the returned key with an audited reason and store the replacement secret. Reconciliation is ERP-owned. Keep mapping rows in states such as `pending_lockwell`, `active`, `failed_lockwell`, `disabled`, and `delete_ready`; compare them with `listTenants`, `getTenant`, and `listKeys` output. A committed ERP row with missing Lockwell resources goes back through the outbox. A Lockwell tenant with no active ERP mapping is an orphan: first disable it with an audited reason, revoke purpose keys, and only then decide whether it can be deleted. Use `deleteTenantDryRun` to preview affected buckets, keys, versions, retention, legal holds, and bytes before destructive cleanup. Offboarding is a sequence, not a helper. Disabling the ERP tenant, disabling the Lockwell tenant, revoking purpose keys, retaining fiscal artifacts, deleting non-retained data, and final tenant deletion are separate audited steps. Never delete a Lockwell tenant just because the ERP row was removed; fiscal retention and legal holds can outlive the ERP account. Topology is explicit. In SaaS, TangibleShift operates the regional Lockwell deployment and the ERP backend owns the admin token; browsers and customer code never see it. In customer on-prem server installs, Lockwell runs beside the ERP server as an external daemon owned by the customer/operator, with a scoped provisioning token stored in the ERP secret store. In desktop-local installs, the installer may bootstrap a local `lockwelld`, but retention-grade evidence requires documented backups, key custody, and clock monitoring before fiscal data is stored there. Retention deadlines are enforced by the Lockwell server clock at write/delete time. The ERP may compute the policy date, but on-prem and desktop deployments must monitor NTP/time drift and record the clock source used for fiscal evidence. If clock drift is detected, pause retention-sensitive onboarding, offboarding, and delete workflows until the operator has reconciled the time source. ### Java ERP tenant/company/purpose layout `ErpScopes` keeps the ERP-side mapping table out of Lockwell and visible in ERP code. Store opaque `tenantPublicRef`, `installationRef`, and `lockwellTenantId` values in TangibleShift; then derive company/purpose bucket, prefix, `externalRef`, and scope strings from opaque refs. Bucket names and prefixes must not contain customer legal names. ```java import com.lockwell.sdk.kit.ErpScopes; import com.lockwell.sdk.kit.ErpScopes.Purpose; var mapping = ErpScopes.tenantMapping("ts_tenant_7f3a", "install_9b12", "lwtenant_9"); var exports = ErpScopes.purposePath(mapping, "co_a812", Purpose.EXPORTS); // companies///, never a legal company name. assert objectKey.startsWith(exports.prefix()); kit.ensureKey(mapping.lockwellTenantId(), ErpScopes.exportReadKey(exports, null, "ERP export read key")); ``` The purpose-scoped access-key templates are `fiscalArchiveAppendKey`, `importReadWriteKey`, `temporaryBrowserUploadKey`, `exportReadKey`, `dataRightsReadWriteKey`, and `supportDiagnosticReadKey`. Store the access key id per `(lockwellTenantId, companyRef, purpose)`, then rotate/revoke by purpose with the same generated scope and an audited reason. After rotation, persist the new secret and drop the ERP credential cache; a new `clientForTenant` call with the rotated credentials creates a fresh native token manager. Tests should construct `PurposePath` values and assert `path.prefix()` to make cross-company mistakes fail during review. ## 2. Get a per-tenant client `clientForTenant` returns a [native data-plane client](/guide/data-operations) bound to a tenant's creds. It auto-mints and refreshes the bearer token, so your app never touches token plumbing. Clients are **cached per (tenant, creds)**. Repeated calls for the same tenant return the same client and share one token manager (one minted token, refreshed in place) instead of a token per call. ::: tip Call it per request `clientForTenant` is cheap to call on every request. The cache means you reuse one client and one token, so there is no need to hold the client yourself. ::: ::: code-group ```ts [Node] const creds = await loadCreds("acme"); // from your own secure store const client = kit.clientForTenant("acme", creds); await client.putObject("uploads", "hello.txt", "hi"); const page = await client.listObjects("uploads"); ``` ```go [Go] creds, _ := loadCreds("acme") client, err := kit.ClientForTenant("acme", creds) if err != nil { return err } _, err = client.PutObject(ctx, lockwellnative.PutObjectInput{ Bucket: "uploads", Key: "hello.txt", Body: strings.NewReader("hi"), }) ``` ```java [Java] var creds = loadCreds("acme"); var client = kit.clientForTenant("acme", creds); client.putObject("uploads", "hello.txt", "hi".getBytes()); var page = client.listObjects("uploads", new NativeTypes.ListObjectsOptions()); ``` ::: The tenant a client acts as is determined server-side from the creds (the native token carries the tenant). The `tenantId` argument is only a cache key, so a mismatched id can never escalate across tenants. After a key rotation, pass the new creds and the kit returns a fresh client with a fresh token. ## 3. Configure browser CORS Use `configureBucketCORS` for an existing bucket or when you want to change origins later. CORS is only browser policy: it lets JavaScript send and read cross-origin signed URL responses, but the signed URL token, key scope, bucket policy, quota, object-lock, and audit gates still run. ::: code-group ```ts [Node] await kit.configureBucketCors("acme", "uploads", { rules: [ { allowedOrigins: ["https://app.example.com"], allowedMethods: ["GET", "HEAD", "PUT"], allowedHeaders: ["content-type"], exposeHeaders: ["ETag"], maxAgeSeconds: 600, }, ], }); ``` ```go [Go] _, err := kit.ConfigureBucketCORS(ctx, "acme", "uploads", lockwellnative.CORSConfiguration{ Rules: []lockwellnative.CORSRule{{ AllowedOrigins: []string{"https://app.example.com"}, AllowedMethods: []string{"GET", "HEAD", "PUT"}, AllowedHeaders: []string{"content-type"}, ExposeHeaders: []string{"ETag"}, MaxAgeSeconds: 600, }}, }) ``` ```java [Java] kit.configureBucketCORS("acme", "uploads", new CORSConfiguration(List.of( new CORSRule(List.of("https://app.example.com"), List.of("GET", "HEAD", "PUT"))))); ``` ::: ## 4. Sign a browser upload or download The kit mints short-lived signed URLs the browser uses directly. No credential ever reaches the client. See [signed URLs](/guide/signed-urls) for the full flow. ::: code-group ```ts [Node] const up = await kit.signedUploadUrl(creds, "uploads", "photo.jpg", { ttlSeconds: 300, contentType: "image/jpeg", }); const down = await kit.signedDownloadUrl(creds, "uploads", "photo.jpg", { ttlSeconds: 300 }); // Return up.url / down.url to the browser. Never the creds. ``` ```go [Go] up, _ := kit.SignedUploadURL(ctx, res.Creds, "uploads", "photo.jpg", lockwellkit.SignedUploadURLInput{ TTLSeconds: 300, ContentType: "image/jpeg", }) downURL, _ := kit.SignedDownloadURL(ctx, res.Creds, "uploads", "photo.jpg", 300) ``` ```java [Java] import java.time.Duration; var up = kit.signedUploadUrl(creds, "uploads", "photo.jpg", Duration.ofMinutes(5), "image/jpeg"); var down = kit.signedDownloadUrl(creds, "uploads", "photo.jpg", Duration.ofMinutes(5)); ``` ::: In Node, `signedUploadUrl` / `signedDownloadUrl` accept either creds or an existing `NativeClient` as the first argument. Pass the client you already hold from `clientForTenant` to skip a cache lookup. ## Java ERP storage profiles `StorageProfiles` names the artifact classes an ERP usually has to keep separate. The helpers return native option objects and signed-URL option bundles; they do not perform writes by themselves, and they do not choose legal retention durations or GDPR outcomes for the ERP. ```java import com.lockwell.sdk.kit.StorageProfiles; // fiscal-archive: finalized fiscal PDFs and SAF-T exports. client.createBucket("fiscal-archive", StorageProfiles.fiscalArchiveBucket()); var fiscal = StorageProfiles.fiscalArchiveWrite( "application/pdf", retainUntilRfc3339, "SHA256", sha256Base64, "invoice-2026-0001"); client.putObject("fiscal-archive", "2026/0001.pdf", pdfBytes, fiscal.putOptions()); client.setObjectRetention("fiscal-archive", "2026/0001.pdf", fiscal.retention().mode(), fiscal.retention().retainUntil()); // imports: browser-direct uploads pinned to an imports/ prefix. var importUrl = StorageProfiles.importUploadUrl(Duration.ofMinutes(5), "application/json", 2L * 1024 * 1024, "SHA256", importSha256Base64, "import-job-123", "imports/"); BrowserSignedUrl up = kit.signedUploadUrl(creds, "imports", null, importUrl.ttl(), importUrl.options()); // exports: bounded download URL with response headers and audit context. var exportUrl = StorageProfiles.exportDownloadUrl(Duration.ofMinutes(5), "application/pdf", "attachment; filename=\"export.pdf\"", "ERP export job-123"); BrowserSignedUrl dl = kit.signedDownloadUrl(creds, "exports", "2026/export.pdf", exportUrl.ttl(), exportUrl.options()); ``` Apply retention (and any legal hold) before acknowledging a fiscal archive as finalized. The native API applies retention after the object exists, so ERP startup/reconciliation should use the ERP archive ledger to find finalized object keys whose retention was not applied after a crash and repair them before deletion is possible. Profiles: * `fiscal-archive`: `fiscalArchiveBucket` creates the bucket with versioning and Object Lock enabled; `fiscalArchiveWrite` adds checksum verification, idempotency, `If-None-Match: *`, profile metadata, and a `COMPLIANCE` retention spec supplied with the ERP's retain-until date. * `imports`: `importObjectWrite` and `importUploadUrl` enforce checksum + idempotency; the browser URL is TTL-bounded, prefix-scoped, content-length capped, and still subject to bucket CORS. * `exports`: `exportDownloadUrl` sets response content headers and a signed audit reason on the native signed URL. * `data-rights`: `dataRightsExportWrite` / `dataRightsDownloadUrl` keep GDPR artifacts explicit; the ERP owns expiry and conflicts between erasure requests and fiscal-retention obligations. * `support-bundles`: `redactedSupportBundleWrite` marks redacted support artifacts; redaction happens before upload. Fiscal archive helpers use `COMPLIANCE` retention explicitly. Governance mode is not the fiscal archive recipe. Signed-URL audit reasons are signed but not encrypted in the URL token; use stable job/document references, not secrets, raw personal data, or sensitive free text. ## 5. Verify an incoming webhook `verifyWebhook` reproduces the server's signing exactly (lowercase-hex `HMAC-SHA256` over the raw body) and compares in constant time. Pass the one-time secret returned when the notification config ID is created; GET and same-ID updates omit it. See [webhooks](/guide/webhooks) for the full receiver flow. ::: code-group ```ts [Node] const ok = await kit.verifyWebhook(rawBody, signatureHeader, secret); ``` ```go [Go] ok := lockwellkit.VerifyWebhook(rawBody, signatureHeader, secret) ``` ```java [Java] boolean ok = LockwellKit.verifyWebhook(rawBody, signatureHeader, secret); ``` ::: ## The end-to-end story Putting it together, a multi-tenant app's entire storage layer is: provision once per tenant (store the secret), then for every request bind a cached per-tenant client and either act on objects server-side or hand the browser a signed URL. ```ts // On tenant onboarding (once): const { key } = await kit.provisionTenant(orgId, { defaultBucket: "uploads" }); await saveCreds(orgId, { accessKeyId: key.accessKeyId, secretKey: key.secretKey }); // On every request, resolve the org from the session (never from caller input): const creds = await loadCreds(orgId); // Browser-direct upload: app.post("/api/upload-url", async (req, res) => { const up = await kit.signedUploadUrl(creds, "uploads", req.body.key, { ttlSeconds: 300 }); res.json(up); }); // Server-side list: app.get("/api/files", async (req, res) => { const client = kit.clientForTenant(orgId, creds); const page = await client.listObjects("uploads", { prefix: req.query.prefix }); res.json(page.objects); }); // Webhook receiver: app.post("/hooks/lockwell", async (req, res) => { const ok = await kit.verifyWebhook(await rawBody(req), req.headers["x-lockwell-signature"], secret); res.status(ok ? 200 : 401).end(); }); ``` The Go and Java kits expose the same four operations with the same shapes. See the runnable references in `examples/go-service` and `examples/spring-boot`. The Node edge example is `examples/hono-edge` (see [edge runtimes](/guide/edge-runtimes)). ## Reaching the rest of the Admin API The kit wraps the common path and exposes the underlying admin client for operations it does not wrap (quotas, audit, key rotation, accounts): ::: code-group ```ts [Node] const adminClient = kit.admin; // the underlying AdminClient ``` ```go [Go] adminClient := kit.Admin() // the underlying *lockwelladmin.Client ``` ```java [Java] var adminClient = kit.admin(); // the underlying LockwellAdminClient ``` ::: ## Next steps * [Data operations](/guide/data-operations). The full native object API the per-tenant client exposes. * [Signed URLs](/guide/signed-urls). Browser-direct upload/download in depth. * [Edge runtimes](/guide/edge-runtimes). The kit's edge entry passes Bun, Deno, workerd, and Vercel Edge Runtime smokes; hosted provider deployments remain an external validation target. --- --- url: /guide/signed-urls.md description: >- Native signed URLs let a browser PUT or GET object bytes directly to Lockwell with no credential, scoped to one method and one object and time-limited. --- # Signed URLs A **native signed URL** is a short-lived, single-object URL that carries its own authorization in a `token` query parameter. The browser uses it with no credential. Mint it server-side, hand the URL to the browser, and the browser PUTs or GETs the object bytes directly to Lockwell. The object body never round-trips through your application, and your access-key secret (or admin token) never reaches the client. The S3 presigner supports **GET, PUT, HEAD, and DELETE**. The native API also supports signed **GET and PUT** URLs, so you can sign a direct browser **upload** as well as a download. ```mermaid sequenceDiagram participant B as Browser participant A as Your server participant L as Lockwell B->>A: Request an upload URL A->>L: Sign a PUT URL using the tenant key L-->>A: Short-lived signed URL A-->>B: Return the URL without credentials B->>L: PUT the file bytes directly to the signed URL L-->>B: 200 OK; application never handles bytes ``` ## The security shape * The URL is **method- and resource-bound**. A GET URL can only GET, a PUT URL can only PUT, and each is bound to exactly one bucket + key. * It is **time-limited** by a TTL you set (clamped server-side to `security.max_presign_ttl`). * It can **never exceed the minting key's scope**. The server re-checks the key's scope and bucket policy both when the URL is minted and again when it is used. A read-only key minting a PUT URL is denied with a 403. * The browser holds **only the URL**. It never sees the access-key secret, the native bearer token, or the admin token. When the URL is used, the server also re-checks the underlying key's revocation. A tampered, expired, wrong-method, wrong-resource, revoked, or scope-exceeding URL is rejected (`401` or `403`). ## Mint server-side You can mint directly from the native client (`signUrl`) or, more conveniently, from the [app kit](/guide/app-kit) (`signedUploadUrl` / `signedDownloadUrl`), which also returns the method and headers the browser should send. The kit takes the **tenant's creds** and binds (and caches) a per-tenant client under the hood. ::: code-group ```ts [Node] 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" }, }); const creds = { accessKeyId, secretKey }; // loaded from your own secure store // Upload (PUT): const up = await kit.signedUploadUrl(creds, "inbox", "photo.jpg", { ttlSeconds: 300, contentType: "image/jpeg", }); // up.url, up.method === 'PUT', up.headers (e.g. { 'content-type': 'image/jpeg' }) // Download (GET): const down = await kit.signedDownloadUrl(creds, "inbox", "photo.jpg", { ttlSeconds: 300 }); // down.url, down.method === 'GET' ``` ```go [Go] import "github.com/KelpHect/lockwell/pkg/lockwellkit" creds := lockwellkit.TenantCreds{AccessKeyID: accessKeyID, SecretKey: secretKey} // Upload (PUT): up, err := kit.SignedUploadURL(ctx, creds, "inbox", "photo.jpg", lockwellkit.SignedUploadURLInput{ TTLSeconds: 300, ContentType: "image/jpeg", }) // up.URL, up.Method == "PUT", up.Headers // Download (GET): downURL, err := kit.SignedDownloadURL(ctx, creds, "inbox", "photo.jpg", 300) ``` ```java [Java] import com.lockwell.sdk.kit.KitTypes.TenantCredentials; import java.time.Duration; TenantCredentials creds = new TenantCredentials(accessKeyId, secretKey); // Upload (PUT): var up = kit.signedUploadUrl(creds, "inbox", "photo.jpg", Duration.ofMinutes(5), "image/jpeg"); // up.url(), up.method() == "PUT", up.contentType() // Download (GET): var down = kit.signedDownloadUrl(creds, "inbox", "photo.jpg", Duration.ofMinutes(15)); ``` ::: If you already hold a `NativeClient`, mint directly with `native.signUrl({ method, bucket, key, ttlSeconds })` (Node), `native.SignURL(ctx, lockwellnative.SignURLInput{...})` (Go), or `nativeClient.signUrlResult("GET"|"PUT", bucket, key, ttlSeconds)` (Java). `method` must be `GET` or `PUT`. ## Separate internal and public origins ERP deployments often run the backend against an **internal** service URL but need browser URLs that resolve against a different **public** origin. In the Java app kit, set `signedUrlPublicOrigin` on the builder: the backend still mints against `nativeEndpoint`, but the absolute URL handed to the browser is built from the public origin. ```java LockwellKit kit = LockwellKit.builder() .adminEndpoint("https://admin.internal.svc") .adminToken(adminToken) .nativeEndpoint("https://objects.internal.svc") // backend reaches Lockwell here .signedUrlPublicOrigin("https://objects.example.com") // browser reaches Lockwell here .signedUrlMaxTtl(Duration.ofMinutes(5)) // client-side cap (server cap still applies) .build(); ``` `signedUrlMaxTtl` is an optional client-side cap: the kit clamps a requested TTL down to it before minting, and the server independently clamps to `security.max_presign_ttl`. The effective TTL is `min(requested, client cap, server cap)`. When `signedUrlPublicOrigin` is unset, the kit falls back to `nativeEndpoint` (the legacy behavior). ## Browser upload constraints (Java) For a browser-direct **PUT**, the minting caller can pin properties the browser cannot be trusted to send correctly. These constraints are HMAC-covered in the signed token and enforced by the server at dispatch time — the browser cannot drop them. ```java import com.lockwell.sdk.nativeapi.NativeTypes.SignedUrlOptions; import java.security.MessageDigest; import java.util.Base64; byte[] body = invoiceBytes; String sha256 = Base64.getEncoder().encodeToString( MessageDigest.getInstance("SHA-256").digest(body)); BrowserSignedUrl up = kit.signedUploadUrl(creds, "fiscal-archive", "2026/0001.pdf", Duration.ofMinutes(5), new SignedUrlOptions() .contentType("application/pdf") // pins the stored Content-Type .contentLengthMax(10L * 1024 * 1024) // rejects a larger upload (413) .checksum("SHA256", sha256) // verifies the body bytes .idempotencyKey("invoice-2026-0001")); // a retry collapses onto the first upload ``` * `contentType` **overrides** the browser's `Content-Type` so the stored object carries exactly this type. * `contentLengthMax` rejects an upload whose declared `Content-Length` exceeds it (`413`) before any blob is written. Chunked uploads with no declared length fall back to the server's global `max_object_size`. * `checksum(alg, value)` is injected as the `X-Lockwell-Checksum-` header; the server verifies the body against it and rejects a mismatch (`400`). * `idempotencyKey` is injected as the `Idempotency-Key` header; a browser retry collapses onto the first upload's result. ### Prefix-scoped uploads Instead of an exact key, a PUT mint can authorize any key under a **prefix** — the browser chooses the suffix. This is useful when the browser generates its own object key (e.g. a random id under `imports/`). ```java BrowserSignedUrl up = kit.signedUploadUrl(creds, "imports", null, Duration.ofMinutes(5), new SignedUrlOptions().keyPrefix("imports/")); ``` The returned URL is a prefix-authorized base URL whose query already contains the token. The browser inserts the full object key under the prefix into the path **before** the query string; do not append a suffix after `?token=`. ```js function signedPrefixUploadUrl(baseUrl, objectKey) { const url = new URL(baseUrl); const encodedKey = objectKey.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/"); url.pathname = url.pathname.replace(/\/?$/, "/") + encodedKey; return url.toString(); } const objectKey = `imports/${crypto.randomUUID()}.json`; await fetch(signedPrefixUploadUrl(up.url(), objectKey), { method: "PUT", body: file, }); ``` `keyPrefix` is PUT-only and mutually exclusive with an exact key. The server re-checks the minting key's live scope against the browser-chosen full object key at access time, so the URL can never exceed the key. ## Browser download response overrides (Java) For a browser-direct **GET**, pin the response headers so a fiscal-PDF or export download is predictable. ```java BrowserSignedUrl dl = kit.signedDownloadUrl(creds, "fiscal-archive", "2026/0001.pdf", Duration.ofMinutes(5), new SignedUrlOptions() .responseContentType("application/pdf") .responseContentDisposition("attachment; filename=\"invoice-2026-0001.pdf\"") .auditReason("ERP export invoice-2026-0001")); ``` These map to the S3-compatible `response-content-type` / `response-content-disposition` query parameters, which an authenticated native GET also honors directly. `auditReason` is stored on the signed-URL mint/use audit rows and is signed into the URL token, so the browser cannot rewrite the reason attached to an export. ## Configure browser CORS once For a browser `fetch()` from your app origin, configure CORS on the bucket before handing out signed URLs. This is a bucket admin operation, so the app kit uses a transient admin-scoped key and revokes it immediately; your stored tenant data key stays `read,write,delete` only. ::: code-group ```ts [Node] await kit.configureBucketCors("acme", "inbox", { rules: [ { id: "browser-direct", allowedOrigins: ["https://app.example.com"], allowedMethods: ["GET", "HEAD", "PUT"], allowedHeaders: ["content-type"], exposeHeaders: ["ETag"], maxAgeSeconds: 600, }, ], }); ``` ```go [Go] _, err := kit.ConfigureBucketCORS(ctx, "acme", "inbox", lockwellnative.CORSConfiguration{ Rules: []lockwellnative.CORSRule{{ ID: "browser-direct", AllowedOrigins: []string{"https://app.example.com"}, AllowedMethods: []string{"GET", "HEAD", "PUT"}, AllowedHeaders: []string{"content-type"}, ExposeHeaders: []string{"ETag"}, MaxAgeSeconds: 600, }}, }) ``` ```java [Java] import com.lockwell.sdk.nativeapi.NativeTypes.CORSConfiguration; import com.lockwell.sdk.nativeapi.NativeTypes.CORSRule; import java.util.List; kit.configureBucketCORS("acme", "inbox", new CORSConfiguration(List.of( new CORSRule("browser-direct", List.of("https://app.example.com"), List.of("GET", "HEAD", "PUT"), List.of("content-type"), List.of("ETag"), 600)))); ``` ::: The signed URL token must still match the browser's method and object. A preflight for `PUT` against a signed `GET` URL is rejected, and a matching CORS rule does not make anonymous or unsigned object access possible. ## A realistic upload flow The pattern is always the same. The browser asks your server for a URL, then uploads directly to Lockwell. ### Server route ```ts [Node] // POST /api/upload-url { key, contentType } app.post("/api/upload-url", async (req, res) => { const tenant = resolveTenantFromSession(req); // NEVER from caller input const creds = await loadCreds(tenant); // from your own secure store const up = await kit.signedUploadUrl(creds, "inbox", req.body.key, { ttlSeconds: 300, contentType: req.body.contentType, }); // Return ONLY the URL + how to use it. No key, no secret, no token. res.json({ url: up.url, method: up.method, headers: up.headers }); }); ``` The tenant is resolved server-side from the authenticated session, never from a request path. The tenant on the Lockwell side is also derived from the per-tenant token, so cross-tenant access is structurally impossible. ::: warning Never trust a tenant from caller input Resolve the tenant from the authenticated session, not from a request path, query parameter, or body field. A signed URL inherits the minting key's tenant and scope, so signing with the wrong tenant's creds is the only way to cross a boundary. ::: ### Browser ```js [Browser] // 1) Ask our server for a signed URL. const r = await fetch("/api/upload-url", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ key: "photo.jpg", contentType: file.type }), }); const { url, method, headers } = await r.json(); // 2) PUT the file bytes straight to Lockwell. The browser holds no credential. await fetch(url, { method, headers, body: file }); ``` A download is the mirror image. Mint a GET URL server-side and put it in an ``, an ``, or a `fetch(url)`. The browser reads the bytes directly from Lockwell until the URL expires. ## Notes and non-goals * A signed URL is for **a single object and a single method**. To let a browser list a bucket or perform multiple operations, mint a URL per object or proxy the call through your server with the native client. * Lockwell has **no public or anonymous buckets**. A signed URL is the only way to grant credential-free object access, and it is always scoped and time-limited. See [the three surfaces](/guide/the-three-surfaces) for the full security model. * Use the S3 presigned **PUT** or the native signed **PUT** URL according to the client surface your application already uses. ## Next steps * [The app kit](/guide/app-kit). The end-to-end provision, sign, and upload story. * [Edge runtimes](/guide/edge-runtimes). Mint signed URLs from a Cloudflare Worker or Vercel Edge function. * [Webhooks](/guide/webhooks). React to the object once the browser finishes uploading. --- --- url: /guide/webhooks.md description: >- Lockwell POSTs a signed HMAC-SHA256 event to your endpoint on object create or remove, and verifyWebhook checks each delivery in constant time. --- # Webhooks Lockwell can POST a signed event notification to your endpoint whenever an object is created or removed in a bucket. You configure the target with the native client, and you verify each delivery with a constant-time HMAC in one call. **Webhook is the only delivery target.** SNS, SQS, and Lambda destinations are a deliberate non-goal (the server returns `501`). There is one HTTP(S) target per configuration. ::: warning Keep credentials out of the target URL Query strings are accepted, but a failed delivery currently records the full target URL in the job's `LastError` and append-only audit reason, and operator job output can print it. Use a credential-free HTTPS URL with no tokens, API keys, or secrets in its query string. Authenticate deliveries by verifying Lockwell's HMAC signature headers with the generated signing secret. ::: Notifications are a native-only surface. The S3 `PutBucketNotificationConfiguration` path enforces the same webhook-only rule, and there is no admin-UI control for it. ## Configure a notification `setBucketNotification` wires a webhook for the bucket's `s3:ObjectCreated:*` and `s3:ObjectRemoved:*` events, with optional `prefix` / `suffix` key filters. The Node helper accepts the friendly shorthand `object-created` / `object-removed` (or the canonical `s3:Object*` names) and the `{ prefix, suffix }` filter object. ::: code-group ```ts [Node] const created = await native.setBucketNotification("uploads", { id: "uploads-events", webhookUrl: "https://my-app.example.com/hooks/lockwell", events: ["object-created", "object-removed"], filters: { prefix: "incoming/", suffix: ".pdf" }, }); const signingSecret = created.configs[0].signingSecret; // shown once; store securely // Read it back. The view carries `hasSecret`; signingSecret is omitted: const { configs } = await native.getBucketNotification("uploads"); // Clear it: await native.deleteBucketNotification("uploads"); ``` ```go [Go] created, err := native.SetBucketNotification(ctx, "uploads", lockwellnative.SetBucketNotificationInput{ Configs: []lockwellnative.NotificationConfig{{ ID: "uploads-events", WebhookURL: "https://my-app.example.com/hooks/lockwell", Events: []string{"s3:ObjectCreated:*", "s3:ObjectRemoved:*"}, Filters: []lockwellnative.NotificationFilter{ {Name: "prefix", Value: "incoming/"}, {Name: "suffix", Value: ".pdf"}, }, }}, }) signingSecret := created[0].SigningSecret // shown once; store securely // Read it back (HasSecret only; SigningSecret is empty): views, _ := native.GetBucketNotification(ctx, "uploads") // Clear it: _ = native.DeleteBucketNotification(ctx, "uploads") ``` ```java [Java] import com.lockwell.sdk.nativeapi.NativeTypes.NotificationConfig; var config = new NotificationConfig() .webhookUrl("https://my-app.example.com/hooks/lockwell") .event("s3:ObjectCreated:*") .event("s3:ObjectRemoved:*") .filter("prefix", "incoming/") .filter("suffix", ".pdf"); config.id("uploads-events"); var created = nativeClient.setBucketNotification("uploads", config); String signingSecret = created.configs().get(0).signingSecret(); // shown once // Read it back (each config reports hasSecret(); signingSecret() is null): var current = nativeClient.getBucketNotification("uploads"); // Clear it: nativeClient.deleteBucketNotification("uploads"); ``` ::: Passing an **empty config list** clears the bucket's notification configuration (S3 "clear" semantics). Configuring a notification needs the `admin` verb on the bucket, so a read-only or data-only key is denied with a 403. ## How a delivery is signed Each delivery carries two headers: * **`X-Lockwell-Signature`** is the lowercase-hex `HMAC-SHA256` of the exact request body bytes. * **`X-Lockwell-Event`** is the event type (for quick routing; not part of the signature). The payload body is S3-event-shaped: `{ "Records": [ { "eventName": ..., "s3": { ... } } ] }`. ## Verify an incoming delivery Verify against the raw request bytes with `verifyWebhook`, which reproduces the server's signing exactly and compares in **constant time**. ::: warning Verify the exact bytes Sign the raw body you received. Do not parse and re-serialize the JSON first. Re-encoding changes the bytes and breaks the signature, so verification fails even on a genuine delivery. Read the raw body, verify, then parse. ::: ::: code-group ```ts [Node] import { verifyWebhook, WEBHOOK_SIGNATURE_HEADER_NAME } from "@kelphect/sdk"; app.post("/hooks/lockwell", async (req, res) => { const raw = await readRawBody(req); // exact bytes; do NOT JSON.parse then re-stringify const sig = req.headers[WEBHOOK_SIGNATURE_HEADER_NAME.toLowerCase()]; const ok = await verifyWebhook(raw, sig, process.env.WEBHOOK_SECRET); if (!ok) return res.status(401).end(); const event = JSON.parse(raw.toString("utf8")); for (const record of event.Records ?? []) { /* ...do real work... */ } res.json({ verified: true }); }); ``` ```go [Go] import "github.com/KelpHect/lockwell/pkg/lockwellkit" func handleWebhook(w http.ResponseWriter, r *http.Request) { raw, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // exact bytes sig := r.Header.Get(lockwellkit.WebhookSignatureHeader) if !lockwellkit.VerifyWebhook(raw, sig, secret) { http.Error(w, "invalid signature", http.StatusUnauthorized) return } event := r.Header.Get(lockwellkit.WebhookEventHeader) log.Printf("verified delivery: %s", event) // ...do real work with the verified body... } ``` ```java [Java] import com.lockwell.sdk.kit.LockwellKit; byte[] raw = request.getInputStream().readAllBytes(); // exact bytes String sig = request.getHeader("X-Lockwell-Signature"); if (!LockwellKit.verifyWebhook(raw, sig, secret)) { response.setStatus(401); return; } // ...do real work with the verified body... ``` ::: `verifyWebhook` is exported standalone (no kit instance needed) and is **edge-safe** in Node. It uses WebCrypto HMAC plus a constant-time compare with no `node:crypto`, so it runs unchanged on Cloudflare Workers, Vercel Edge, Bun, and Deno. See [edge runtimes](/guide/edge-runtimes). ## About the signing secret Lockwell generates a 256-bit per-config secret server-side, returns it exactly once when a new config ID is created, and stores only encrypted material thereafter. Capture `signingSecret` from the creation response and put it in the receiver's secret manager. GET and same-ID updates expose only `hasSecret`; losing the value requires rotation through a new config ID. S3 PUT exposes the equivalent one-time map in `X-Lockwell-Webhook-Secrets`. If your receiver has no secret to verify against, **fail closed** (reject the delivery) rather than trust an unverified body. ## Next steps * [The app kit](/guide/app-kit). `verifyWebhook` is also a method on `LockwellKit`. * [Edge runtimes](/guide/edge-runtimes). Receive and verify webhooks on a Worker. * [Data operations](/guide/data-operations). Act on the object the event points to. --- --- url: /guide/edge-runtimes.md description: >- The Node SDK provides a node-free /edge entry designed for Cloudflare Workers, Vercel Edge, Bun, and Deno; named Bun, Deno, workerd, and Vercel Edge Runtime smokes pass; hosted provider deployment evidence remains pending. --- # Edge runtimes The Node SDK's **native client** and **app kit** have a node-free `/edge` entry designed for Cloudflare Workers, Vercel Edge, Bun, and Deno. They import nothing from `node:*` and are built on web globals (`fetch`, `ReadableStream`, `TextEncoder`, `btoa`, `crypto.subtle`). Static import-graph and Node-hosted WebCrypto tests cover that portable boundary. A committed smoke also runs under Bun and Deno, exercising WebCrypto plus native token mint and request handling. The Hono example also runs under Wrangler's local workerd engine and Vercel's Edge Runtime VM. B-009 remains open only for hosted deployment provenance and a protected live flow in authorized provider projects. The one exception is the **S3 `Client`**, whose SigV4 signer uses `node:crypto`. It is Node-only by design and is not exported from the edge entry. On the edge you use the native data plane instead, which is the recommended surface anyway. ## Use the `/edge` entry Import from the dedicated edge entry, `@kelphect/sdk/edge`: ```ts import { LockwellKit, NativeClient, AdminClient, verifyWebhook, WEBHOOK_SIGNATURE_HEADER_NAME, sha256ChecksumBase64, } from "@kelphect/sdk/edge"; ``` The `/edge` subpath re-exports only the `node:*`-free modules (`native.js`, `admin.js`, `kit.js`, and helpers). Import from `/edge`, not the default barrel. The default barrel (`@kelphect/sdk`) also re-exports the Node-only S3 `Client`, so a bundler that follows the barrel drags `node:crypto` into the edge bundle and forces a `nodejs_compat` flag or polyfill. Importing from `/edge` bundles to zero node-only code with no compatibility flag. ::: tip What is enforced The SDK's `test/edge.test.js` statically walks the `/edge` import graph and **fails the build** if any `node:` specifier becomes reachable. It also runs a request with `node:crypto` poisoned at the module loader. This enforces the node-free import boundary. Bun, Deno, Wrangler/workerd, and Vercel Edge Runtime add real execution; hosted provider deployments remain a separate evidence gate. ::: ## A Cloudflare Workers / Hono example This is the intended shape of the [`examples/hono-edge`](https://github.com/RusticStack/lockwell/tree/main/examples/hono-edge) app. It has not yet been validated as a deployed Cloudflare Worker. Secrets should live only in edge env bindings (`c.env`) and never reach the browser; the browser should receive only a short-lived signed URL. ```ts import { Hono } from "hono"; import { cors } from "hono/cors"; // Edge-safe imports only. The S3 Client is not even reachable through /edge. import { LockwellKit, verifyWebhook, WEBHOOK_SIGNATURE_HEADER_NAME } from "@kelphect/sdk/edge"; const app = new Hono(); app.use("/api/*", cors()); // env bindings are per-request on Workers, so build the kit from c.env per call. function kitFor(env) { return new LockwellKit({ admin: { endpoint: env.LOCKWELL_ADMIN_ENDPOINT, token: env.LOCKWELL_ADMIN_TOKEN }, native: { endpoint: env.LOCKWELL_PUBLIC_ENDPOINT }, }); } // Mint a browser-direct signed upload URL (no credential leaves the server). app.post("/api/signed-upload", async (c) => { const { tenantId, creds } = resolveTenant(c); // server-side; never from a forgeable path const { key, contentType } = await c.req.json(); const kit = kitFor(c.env); const native = kit.clientForTenant(tenantId, creds); const up = await kit.signedUploadUrl(native, creds.bucket, key, { ttlSeconds: 300, contentType, }); // Return ONLY the URL + how to use it. return c.json({ url: up.url, method: up.method, headers: up.headers }); }); // Stream an upload straight through the Worker to the native PUT (no buffering). app.put("/api/objects/:key{.+}", async (c) => { const { tenantId, creds } = resolveTenant(c); const native = kitFor(c.env).clientForTenant(tenantId, creds); const res = await native.putObject(creds.bucket, c.req.param("key"), c.req.raw.body, { contentType: c.req.header("content-type") || "application/octet-stream", }); return c.json({ etag: res.etag, versionId: res.versionId }); }); // Receive + verify a webhook with the constant-time WebCrypto HMAC. app.post("/api/webhooks/lockwell", async (c) => { const secret = c.env.WEBHOOK_SHARED_SECRET; if (!secret) return c.json({ error: "no verification secret configured" }, 501); const sig = c.req.header(WEBHOOK_SIGNATURE_HEADER_NAME) || ""; const raw = new Uint8Array(await c.req.raw.clone().arrayBuffer()); // exact bytes if (!(await verifyWebhook(raw, sig, secret))) return c.json({ error: "invalid signature" }, 401); const event = await c.req.json(); return c.json({ verified: true, recordCount: (event.Records || []).length }); }); export default app; // Hono's `app` IS a { fetch(request, env, ctx) } Workers handler ``` `c.req.raw.body` is a web `ReadableStream`. The native client streams it straight to Lockwell with no whole-object buffering. The native bearer token is minted and refreshed by the SDK and never leaves the server. ## Per-runtime integration sketches The Cloudflare and Vercel deployment sections are configuration sketches, not records of hosted deployments. Their official local runtime engines pass alongside Bun and Deno; B-009 must clear before hosted deployments are claimed. **Cloudflare Workers.** `export default app`. Set the two endpoint URLs as `wrangler.toml` `[vars]` and the admin token and shared secrets via `wrangler secret put`. The node-free entry is intended to avoid `nodejs_compat`; validate that in the target Worker project before relying on it. **Vercel Edge.** Re-export `app.fetch` from an Edge Function or route handler with `export const runtime = 'edge'`, passing `process.env` as the env arg: `app.fetch(request, process.env)`. **Bun.** `Bun.serve({ port, fetch: (req) => app.fetch(req, process.env) })`. **Deno.** `Deno.serve({ port }, (req) => app.fetch(req, Deno.env.toObject()))`. Import-map the `@kelphect/sdk` and `hono` specifiers in `deno.json`. ## Edge-safe checksums A native idempotent PUT needs a body-integrity checksum (see [idempotency keys](/guide/conditional-writes#idempotency-keys)). On the edge, `sha256ChecksumBase64` computes the SHA-256 with `crypto.subtle`, no `node:crypto`: ```ts import { sha256ChecksumBase64 } from "@kelphect/sdk/edge"; const body = "invoice-payload"; await native.putObject("billing", "invoices/2026-001.json", body, { idempotencyKey: "invoice-2026-001", checksums: { sha256: await sha256ChecksumBase64(body) }, }); ``` CRC32C and CRC64NVME checksums can be precomputed and passed directly. ## What runs where | Surface | Edge-safe? | Notes | | ----------------------------- | --------------- | -------------------------------------------------------------------- | | `NativeClient` (data plane) | Node-free entry | Bun/Deno/workerd/Vercel-VM smokes pass; hosted deploys pending. | | `LockwellKit` (app kit) | Node-free entry | `clientForTenant`, signed URLs, `verifyWebhook` are node-free. | | `AdminClient` (control plane) | Node-free entry | Uses global `fetch`; typically a server-side/admin context. | | `verifyWebhook` | Node-free entry | WebCrypto HMAC + constant-time compare; hosted deploys pending. | | S3 `Client` (SigV4) | No | Node-only. Its signer uses `node:crypto`. Not exported from `/edge`. | ## Next steps * [Data operations](/guide/data-operations). The native API the edge client exposes. * [Signed URLs](/guide/signed-urls). The browser-direct upload pattern shown above. * [Webhooks](/guide/webhooks). Receiving and verifying deliveries. --- --- url: /guide/deployment.md description: >- Deploy Lockwell as one container with one volume via Docker Compose, with two listeners, an at-rest master key, TLS at the proxy, and online backups. --- # Deployment Lockwell runs as a **single container** backed by **one named volume**: the S3 and native APIs, the embedded BadgerDB metadata engine, and always-on at-rest encryption, all in one process. There is no external database, message broker, or cache to run alongside it. This page covers the single-node Docker Compose deployment, the two-listener model, where the master key and data live, TLS posture, and backup/restore on the embedded engine. For the authoritative, full procedures, see the repository [deployment docs](https://github.com/RusticStack/lockwell/tree/main/docs/deployment) and [production docs](https://github.com/RusticStack/lockwell/tree/main/docs/production). ## Single-node Docker Compose The [`lockwell-deploy`](https://github.com/RusticStack/lockwell-deploy) repository ships everything needed to run a node: a `Dockerfile` and `docker-compose.yml` that assemble a thin runtime image from the official prebuilt binary, an `.env.example`, the production config (`examples/lockwell.production.toml`), and reverse-proxy examples. **No source is compiled**: the image build only downloads and checksum-verifies the released static binary for your architecture (`linux/amd64` or `linux/arm64`). ```bash git clone https://github.com/RusticStack/lockwell-deploy.git cd lockwell-deploy cp .env.example .env ``` Set the initial S3 credentials in `.env`. The deploy **fails fast** if they are unset, so there is never a default credential: ```ini # Initial S3 access key, created on first boot (idempotent). LOCKWELL_ROOT_ACCESS_KEY_ID=lockwell-admin LOCKWELL_ROOT_SECRET_KEY=change-me-to-a-long-random-secret-min-16-chars LOCKWELL_ROOT_TENANT=root # Optional first admin web-UI login (separate from the S3 key above). LOCKWELL_ADMIN_USERNAME=admin LOCKWELL_ADMIN_PASSWORD=change-me-to-a-different-long-random-secret LOCKWELL_ADMIN_ROLE=owner # Published host ports. /metrics is UNAUTHENTICATED, so bind the admin port to localhost. LOCKWELL_S3_PORT=9000 LOCKWELL_ADMIN_PORT=9001 LOCKWELL_ADMIN_BIND=127.0.0.1 ``` Generate strong secrets with `openssl rand -hex 32`. Then bring it up: ```bash docker compose up -d --build ``` The `--build` step assembles a thin runtime image from the official static binary (`debian-slim`, non-root uid 1000). It downloads and checksum-verifies the released binary, compiling no source. Compose then runs an **idempotent first-boot bootstrap** (creates the root tenant, the root S3 access key, and optionally the first admin user) and starts `lockwelld`. Restarts never clobber your credentials. Verify: ```bash curl -fsS http://127.0.0.1:9000/health # always-200 liveness on the public port curl -fsS http://127.0.0.1:9000/readyz # deep readiness; pings the metadata engine + storage docker compose logs -f lockwell ``` The bootstrap must run before the daemon because the embedded engine takes an **exclusive single-process directory lock**. Only the daemon may hold the store open. ## The two listeners | Port | Bind (default) | Surface | | -------- | --------------------- | --------------------------------------------------------------------------------------- | | **9000** | published (public) | S3 API, the native API (`/api/v1`), bearer token mint, signed URLs, `/health` `/readyz` | | **9001** | `127.0.0.1` (private) | Admin web UI (`/admin`), JSON Admin API (`/admin/api/v1`), Prometheus `/metrics` | Put a TLS-terminating reverse proxy in front of port **9000**. **Keep port 9001 private.** `/metrics` is **unauthenticated**. Reach the admin port over an SSH tunnel or a firewalled private network, and never attach a public domain to it. ```bash ssh -L 9001:127.0.0.1:9001 youruser@yourhost # then open http://localhost:9001/admin and scrape http://localhost:9001/metrics ``` This split is why the SDK takes two endpoints. The data planes (S3 + native) live on 9000; the Admin API and app-kit provisioning live on 9001. See [Installation](/guide/installation#prerequisites). ## TLS posture The public port binds **plaintext inside the container**. Lockwell expects TLS to be terminated by a reverse proxy in front of it. ::: info TLS is the proxy's job by design Lockwell does not terminate TLS itself. This keeps certificate handling out of the daemon and lets you use whatever proxy you already run. Always front the public port with a TLS-terminating proxy in production. ::: Two object-storage concerns matter at the proxy: raise the body-size limit (large PUTs and multipart parts), and **stream** the request body rather than buffering it to disk. ```text s3.example.com { reverse_proxy 127.0.0.1:9000 request_body { max_size 5GB } } ``` Caddy provisions Let's Encrypt certificates automatically and streams bodies by default. With nginx, set `client_max_body_size`, `proxy_request_buffering off`, and forward `Host` / `X-Forwarded-Proto` so SigV4 and signed URLs see the original host. The full proxy recipes (Caddy and nginx) and firewall policy are in the [Docker Compose deployment guide](https://github.com/RusticStack/lockwell/tree/main/docs/deployment). Open only `443` to the internet: ```bash ufw default deny incoming ufw allow 22/tcp # SSH ufw allow 443/tcp # HTTPS (reverse proxy) ufw enable ``` ## Where the master key and data live The entire state (object blobs, the embedded metadata engine, and the auto-generated at-rest **master key**) lives in the single `lockwell-data` volume mounted at `/var/lib/lockwell`. The master key is written there during the first-boot bootstrap. **Losing the master key makes the encrypted data unrecoverable**, so back it up out of band. For stronger separation, mount the key from a secret on a path outside the data volume via `LOCKWELL_MASTER_KEY_FILE`. ::: warning Back up the master key separately The master key lives in the same volume as the data. A volume snapshot alone is not key separation. Copy the key to a different location, or mount it from a secret with `LOCKWELL_MASTER_KEY_FILE`, so a data-volume loss is recoverable. ::: ## Data residency and local placement Lockwell does not choose cloud regions or fan object bytes out to a remote storage service. Residency comes from where you run `lockwelld`, where `storage.data_dir` lives, and where backups plus key escrow are stored. `server.region` is a SigV4/S3 compatibility label, not a placement or replication engine. For SaaS, pin the Lockwell node, volume/blob store, metadata store, backups, and data-encryption key custody to EU/Portugal-approved infrastructure and expose only that endpoint to the ERP. For on-prem/server and desktop-local installs, point `storage.data_dir` at the customer-controlled local volume and keep backups, the master key, and the data-encryption key directory in the same customer custody boundary. There are no remote-backend or external-KMS storage network calls by default. ## Backup and restore Take an **online** metadata backup from the running daemon (no downtime). The CLI is an S3 client: it signs a request to the daemon with **admin-scoped** credentials and writes the metadata to `--out` (object blobs are captured separately via the data volume). ```bash docker compose exec \ -e AWS_ACCESS_KEY_ID=lockwell-admin \ -e AWS_SECRET_ACCESS_KEY=change-me-to-a-long-random-secret-min-16-chars \ lockwell lockwell metadata-backup \ --endpoint http://127.0.0.1:9000 \ --out /var/lib/lockwell/metadata-backup.bin ``` **Restore is offline.** The embedded engine takes an exclusive single-process directory lock, and the restore loads the backup into a **fresh** data dir, so the daemon must be stopped first: ```bash docker compose stop lockwell # lockwell metadata-restore -c /etc/lockwell/lockwell.toml --from --yes docker compose start lockwell ``` Both work directly on the embedded engine. There is no external migration step and nothing to dump/restore from a separate database. For the full procedure (blob backup, master-key handling, and `lockwell backup-verify`), see the [backup-restore docs](https://github.com/RusticStack/lockwell/tree/main/docs). ## Key rewrap Lockwell rotates encryption keys on the embedded engine without downtime for reads: * **Access-key master rewrap** re-encrypts stored access-key secrets under a new master key (`lockwell access-keys rewrap-master-key`, with a backup file and a `rollback-master-key-rewrap` companion). * **Tenant data-key rewrap** re-encrypts a tenant's objects under a fresh data key as a tracked, resumable job: `lockwell keys rewrap plan` (read-only), `start`, `status`, and `run`. Both are CLI maintenance workflows that never print plaintext secrets. See the repository CLI reference for the exact flags and operator contract. ## Updating Bump `LOCKWELL_VERSION` in `.env` to the new release, then: ```bash git pull docker compose up -d --build ``` Compose rebuilds the image from the new binary and reattaches the existing `lockwell-data` volume. The metadata engine opens **in place** with no external migration step. Watch the logs and confirm `/readyz` returns 200. ## Sizing The embedded engine and single static binary keep the footprint small. A 1 vCPU, 1 GiB VPS comfortably runs a small workload. The dominant memory tunable is `[metadata].block_cache_mb` (64 to 128 MiB is a good range with encryption on). Choose a durability tier with `[metadata].sync_interval_ms` and `[storage].relaxed_durability` (STRICT by default: an acked write survives sudden power loss). Dedup and compression are ON in the baked config, reducing physical bytes. See the production [configuration](https://github.com/RusticStack/lockwell/tree/main/docs/production) docs for the full TOML reference. ## Honest scope * **Single-node.** This is a single-node deployment: one container, one volume, the embedded engine. There is no built-in clustering or multi-node replication. Durability comes from the durability tier plus your own volume snapshots and backups. * **TLS is your proxy's job.** Lockwell binds plaintext behind a reverse proxy. It does not terminate TLS itself. * **No public/anonymous buckets, no SSE-KMS, no IAM/STS.** These are deliberate non-goals. See [the three surfaces](/guide/the-three-surfaces). Credential-free object access is only ever via a scoped, time-limited [signed URL](/guide/signed-urls). ## Next steps * [The app kit](/guide/app-kit). Point your app at the public + admin endpoints. * [Data operations](/guide/data-operations). The native API on the public listener. * [Tenancy and auth](/guide/tenancy-and-auth). The Admin API on the private listener. --- --- url: /guide/operations-and-observability.md description: >- Use Lockwell native and admin SDKs for health, readiness, quotas, keys, audit queries, request correlation, and safe operator workflows. --- # Operations and observability Object data belongs on the S3 or native client. Tenant provisioning, key rotation, quotas, usage, and audit queries belong on the admin client against the private admin listener. ## Health and readiness Native and admin clients expose typed `healthz` and `readyz` probes in Node and Java; the Go native/admin packages expose the matching health methods. Probe requests do not send object or admin credentials. Health means the process answers; readiness includes dependency/component state and is the safer load-balancer gate. ::: code-group ```go [Go] health, err := admin.Health(ctx) ready, err := admin.Readiness(ctx) fmt.Println(health.Status, ready.Status) ``` ```ts [Node] const health = await admin.healthz({ signal, timeoutMs: 2_000 }); const ready = await admin.readyz({ signal, timeoutMs: 2_000 }); console.log({ health: health.status, ready: ready.status, components: ready.components }); ``` ```java [Java] HealthResult health = admin.healthz(); HealthResult ready = admin.readyz(); System.out.println(health.status() + " " + ready.status()); ``` ::: Expected healthy output is structurally similar to `ok ready`; component names and unknown future statuses must be preserved rather than coerced to success. ## Quota and usage Read current usage before changing a quota. Admin mutations support dry-run where the server contract permits it. ::: code-group ```go [Go] usage, err := admin.GetUsage(ctx, "acme") quota, plan, err := admin.SetQuota(ctx, "acme", lockwelladmin.SetQuotaInput{ Bytes: 100 << 30, DryRun: true, }) fmt.Println(usage.Bytes, quota, plan) ``` ```ts [Node] const usage = await admin.getUsage("acme"); const plan = await admin.setQuota("acme", 100 * 1024 ** 3, { dryRun: true }); console.log({ used: usage.bytes, plan }); ``` ```java [Java] var usage = admin.getUsage("acme"); var plan = admin.setQuotaDryRun("acme", 100L << 30); System.out.println(usage + " " + plan); ``` ::: A quota denial is HTTP 507 with machine code `quota_exceeded`; it is not retryable. Catch the typed native/admin error, record its request id, and surface a capacity action rather than retrying the same write. ## Key rotation Created and rotated access-key secrets are returned once. Persist the new secret before switching callers, verify a request with the new key, then revoke the old key with an audit reason. `listKeys` never returns secrets. Use dry-run for supported mutation planning and never print a secret in ordinary logs or expected-output examples. ## Audit and webhook correlation Use the success metadata callback and typed error request id to correlate an application operation with `queryAudit`. Webhook notifications carry their own signed delivery; retain the signing secret only from the create response, because later reads expose `hasSecret` but not the secret. Verify the raw request bytes before JSON parsing. ## Denial example All clients preserve machine-readable failures. A retention-blocked delete should look like this at the application boundary (values are illustrative): ```text status=412 code=retention_blocked request_id=req_... action=wait_until_retention_expires ``` Do not retry 401, 403, 409 idempotency conflict, 412 retention/legal-hold/conditional failures, or 507 quota failures without changing the underlying condition. ## Admin boundaries Admin clients cover tenant, account, key, quota, usage, and audit workflows. Encryption-key rewrap, lifecycle/repair, placement, backup/restore, and daemon configuration remain CLI or authenticated admin-Web-UI workflows; the SDKs do not invent proposal-only methods for them. --- --- url: /guide/errors-and-retries.md description: >- Handle Lockwell SDK errors with structured codes and Is* helpers, and tune S3 or Java native retry policy behavior for safe retries. --- # Errors and retries Every Lockwell SDK surface raises a structured error carrying a stable machine-readable code, the HTTP status, and a request id you can correlate with a server-side audit row. Each surface has its own error type and a set of `Is*` helpers so you branch on the failure without parsing strings. ## Error types per surface | Surface | Go type | Node class | Java exception | | ----------------- | ----------------------------- | ------------- | ----------------- | | S3 data plane | `*lockwellsdk.APIError` | `APIError` | `ApiException` | | Native data plane | `*lockwellnative.NativeError` | `NativeError` | `NativeException` | | Admin API | `*lockwelladmin.AdminError` | `AdminError` | `AdminException` | Each error exposes the same four stable fields: a `Code` (S3-style like `NoSuchKey`, or native/admin codes like `not_found`), a human `Message`, the `StatusCode`, and a `RequestID`. Native/admin JSON errors additionally preserve optional RFC 9457 `Type`, `Title`, `Detail`, and `Instance` members plus an inspection-safe `Extensions` map/object for unknown fields; `Detail` is the human fallback when `Message` is absent. S3 XML errors remain separate and do not gain these native/admin fields. Secrets are never included in an error message; the SDKs redact credentials and query strings before wrapping a transport error. If a problem body is malformed, bodyless, or omits both `code` and `type`, all three SDKs use the status-derived machine code `http_` while treating the HTTP status as authoritative. ## The Is\* helpers Branch on the helper, not on the raw status, so your code reads cleanly and survives a code change on the server. ### S3 client The S3 client ships one helper, `IsNotFound`, which is true for `NoSuchKey`, `NoSuchBucket`, `NoSuchUpload`, `NotFound`, or a bare 404. For everything else, inspect the error's `Code` and `StatusCode`. ::: code-group ```ts [Node] import { isNotFound } from "@kelphect/sdk"; try { await s3.headObject("reports", "missing.txt"); } catch (err) { if (isNotFound(err)) { // create it, or treat as absent } else if (err.statusCode === 412) { // a conditional precondition failed } else throw err; } ``` ```go [Go] _, err := s3.HeadObject(ctx, "reports", "missing.txt") if lockwellsdk.IsNotFound(err) { // create it, or treat as absent } var api *lockwellsdk.APIError if errors.As(err, &api) && api.StatusCode == http.StatusPreconditionFailed { // a conditional precondition failed } ``` ```java [Java] import com.lockwell.sdk.ApiException; try { s3.headObject("reports", "missing.txt"); } catch (ApiException e) { if (e.isNotFound()) { // create it, or treat as absent } else if (e.statusCode() == 412) { // a conditional precondition failed } else throw e; } ``` ::: ### Native client The native error has status helpers and exact-code helpers for 409/412 subtypes: | Status/code | Meaning | Go | Node | Java | | ------------------------------- | ---------------------------------------- | -------------------------------- | ---------------------------- | -------------------------- | | 401 | bad/missing/expired token, revoked key | `IsUnauthorized` | `isNativeUnauthorized` | `e.isUnauthorized()` | | 403 | access-key scope or bucket-policy denial | `IsForbidden` | `isNativeForbidden` | `e.isForbidden()` | | 404 | no such bucket or key | `IsNotFound` | `isNativeNotFound` | `e.isNotFound()` | | 409 / `already_exists` | bucket already exists | `IsAlreadyExists` / `IsConflict` | `isNativeConflict` | `e.isConflict()` | | 409 / `idempotency_conflict` | idempotency key reused differently | `IsIdempotencyConflict` | inspect `code` | `ErpErrors` | | 409 / `idempotency_in_progress` | first idempotent operation still running | `IsIdempotencyInProgress` | inspect `code` | `ErpErrors` | | 412 / `precondition_failed` | conditional precondition not met | `IsPreconditionFailed` | `isNativePreconditionFailed` | `e.isPreconditionFailed()` | | 412 / `retention_blocked` | retention window blocks mutation | `IsRetentionBlocked` | inspect `code` | `ErpErrors` | | 412 / `legal_hold_blocked` | legal hold blocks mutation | `IsLegalHoldBlocked` | inspect `code` | `ErpErrors` | | 507 | tenant storage quota exceeded | `IsQuotaExceeded` | `isNativeQuotaExceeded` | `e.isQuotaExceeded()` | ::: code-group ```ts [Node] import { isNativePreconditionFailed, isNativeQuotaExceeded } from "@kelphect/sdk"; try { await native.putObject("reports", "once.txt", body, { ifNoneMatch: "*" }); } catch (err) { if (isNativePreconditionFailed(err)) { // already exists } else if (isNativeQuotaExceeded(err)) { // tenant is over quota } else throw err; } ``` ```go [Go] _, err := native.PutObject(ctx, lockwellnative.PutObjectInput{ Bucket: "reports", Key: "once.txt", Body: body, IfNoneMatch: "*", }) switch { case lockwellnative.IsPreconditionFailed(err): // already exists case lockwellnative.IsQuotaExceeded(err): // tenant is over quota case err != nil: return err } ``` ```java [Java] import com.lockwell.sdk.nativeapi.NativeException; try { nativeClient.putObject("reports", "once.txt", body, new PutOptions().ifAbsent()); } catch (NativeException e) { if (e.isPreconditionFailed()) { // already exists } else if (e.isQuotaExceeded()) { // tenant is over quota } else throw e; } ``` ::: ### Java ERP classification Java ERP integrations can call `ErpErrors.classify(Throwable)` to map either `AdminException` or `NativeException` to a stable `Category`, `RetryDecision`, RFC 9457-style `problemType`, audit `reason`, and request id. The classification does not copy raw exception messages, so signed URLs, secrets, sensitive object keys, tenant names, and customer names do not leak into ERP problem responses. The ERP categories are `NOT_FOUND`, `ALREADY_EXISTS`, `FORBIDDEN`, `UNAUTHORIZED`, `TENANT_DISABLED`, `KEY_REVOKED`, `KEY_EXPIRED`, `QUOTA_EXCEEDED`, `RATE_LIMITED`, `RETENTION_BLOCKED`, `LEGAL_HOLD_BLOCKED`, `PRECONDITION_FAILED`, `IDEMPOTENCY_CONFLICT`, `IDEMPOTENCY_IN_PROGRESS`, `VALIDATION_ERROR`, `TRANSIENT_UPSTREAM`, and `UNKNOWN`. The classifier keys on exact JSON codes such as `key_revoked`, `tenant_disabled`, `quota_exceeded`, `retention_blocked`, `legal_hold_blocked`, `idempotency_conflict`, and `idempotency_in_progress`. Retrying writes after `RATE_LIMITED`, `TRANSIENT_UPSTREAM`, or `IDEMPOTENCY_IN_PROGRESS` requires the same idempotency key plus a body-binding checksum; otherwise surface the failure and reconcile the ERP row or operator action. ### Admin client The admin error exposes `IsNotFound`, `IsForbidden`, and `IsPreconditionFailed` (Go); the Node `AdminError` ships `isAdminNotFound`; the Java `AdminException` exposes `isNotFound`, `isForbidden`, `isUnauthorized`, and `isRetentionBlocked` (the 412 retention-blocked case). See [tenancy and auth](/guide/tenancy-and-auth) for the admin surface. ## Status mapping On the JSON native and Admin API surfaces, a given failure code maps to one status. The S3 XML compatibility surface keeps S3-native statuses where compatibility requires them, so exact JSON codes and SDK guards are the source of truth when one status has several meanings: | Status | Cause | | ------ | ---------------------------------------------------------------------- | | 400 | malformed request, or a checksum mismatch (`BadDigest`) on a write | | 401 | missing/invalid/expired credentials or token, or a revoked key | | 403 | scope or bucket-policy denial | | 404 | no such bucket, key, version, or upload | | 409 | a conflicting create or in-progress idempotency key | | 412 | a conditional precondition failed, or JSON retention/legal-hold denial | | 429 | rate limited | | 5xx | a server-side or transient failure | | 507 | the tenant storage quota was exceeded | ## The retry policy The S3 clients, plus the Java native client, retry safe and idempotent requests on transient failures. A policy has four knobs: | Field | Meaning | | ------------- | ----------------------------------------------------------------- | | `MaxAttempts` | total attempts including the first; a value of 1 disables retries | | `BaseBackoff` | the delay before the second attempt; it doubles each attempt | | `MaxBackoff` | the cap on the exponential delay | | `Jitter` | the fraction (0..1) of the delay added as uniform random jitter | The two ready-made policies: * **Default**: up to 3 attempts, 100ms base backoff doubling to a 2s cap, with full jitter. * **Disabled**: every request is attempted exactly once. The backoff before attempt `n+1` is `min(MaxBackoff, BaseBackoff * 2^(n-1))`, plus uniform jitter in `[0, Jitter*delay]`. Jitter keeps a fleet of clients from synchronizing their retries after a shared blip. ::: code-group ```ts [Node] import { Client, RetryPolicy } from "@kelphect/sdk"; // The Node S3 client defaults to one attempt; opt in with a policy: const s3 = new Client({ endpoint: "https://objects.example.com", accessKeyId: process.env.LOCKWELL_ACCESS_KEY_ID, secretKey: process.env.LOCKWELL_SECRET_KEY, retry: RetryPolicy.default(), // or { maxAttempts: 5 }, or RetryPolicy.disabled() }); ``` ```go [Go] // The Go S3 client retries by default; tune or disable it: s3, err := lockwellsdk.New(endpoint, creds, lockwellsdk.WithRetryPolicy(lockwellsdk.DefaultRetryPolicy())) // Turn retries off: s3, err = lockwellsdk.New(endpoint, creds, lockwellsdk.WithRetryPolicy(lockwellsdk.DisabledRetryPolicy())) ``` ```java [Java] import com.lockwell.sdk.RetryPolicy; // The Java S3 client defaults to one attempt; enable on the builder: var s3 = LockwellClient.builder() .endpoint("https://objects.example.com") .credentials(creds) .retryPolicy(RetryPolicy.defaults()) // or RetryPolicy.of(...), or .disabled() .build(); ``` ::: The Go S3 client retries by default. The Node and Java S3 clients default to a single attempt for backward compatibility; pass a policy to opt in. Node S3, native, and admin clients also accept `timeoutMs` and `signal` constructor options. `timeoutMs` is a bounded per-attempt limit (maximum 30 seconds); a timeout can be retried when the operation is replay-safe. `signal` is a caller-owned `AbortSignal` for the whole client and stops the in-flight attempt and any retry when aborted. The Java native client defaults to `RetryPolicy.defaults()`. Pass `retryPolicy(RetryPolicy.disabled())` on `LockwellNativeClient.builder()` when a service owns the retry loop itself. ### Which requests retry A request is replayed only when it is safe to replay: * `GET`, `HEAD`, and `DELETE` are idempotent by HTTP semantics, so they always retry under the policy. * A buffered-body `PUT` or `POST` retries only when it carries an [idempotency key](/guide/conditional-writes), so the server collapses a duplicate effect. * S3 streaming uploads are never retried; their source is already consumed. Java native streaming uploads can retry only when the request is keyed and the `Supplier` can open a fresh stream. ::: tip Attach an idempotency key to a write you want the client to retry. It is what lets a buffered `PutObject` or `POST` replay safely after a 5xx or a transport error. ::: A response retries on a 5xx or a 429. A 4xx other than 429 is a client error and is never retried. A transport-level error (connection refused, reset, timeout) is retried, because the SDK only ever reaches the retry path for a request it already decided is safe to replay. A `Retry-After` header on `429` or `5xx` raises the delay when it is longer than the local backoff; Java accepts delta-seconds and HTTP-date forms. Each S3 retry attempt is re-signed with a fresh timestamp, since SigV4 signatures are time-bound. ## Retries on the native client All native clients refresh their bearer token before expiry, and a single `401` triggers exactly one token re-mint and one replay. Token acquisition is single-flight, so a burst of concurrent calls mints at most one token. The Java native client also retries transient transport errors, `429`, and `5xx` responses through `RetryPolicy.defaults()` by default. `GET`, `HEAD`, and `DELETE` replay automatically. `PUT` and `POST` replay only when the request carries `Idempotency-Key`, which the Java `PutOptions.idempotencyKey(...)` helper sets for object writes. Pass `RetryPolicy.disabled()` to attempt each request once. The Go and Node native clients keep only the token-refresh retry. Wrap your own retry loop around a Go or Node native call if you want to retry transient 5xx responses, and pair writes with an idempotency key so a replay is safe. See [Java native client](/sdks/java-native) for JVM production defaults. ## Idempotency Idempotency is how a write becomes safe to retry. Attach an idempotency key to a `PutObject` or `CompleteMultipartUpload` and a retry carrying the same key replays the stored result instead of writing twice. On the S3 client the key is sent as the signed `X-Lockwell-Idempotency-Key` header, so it cannot be stripped or altered in transit. On the native client the key is the `Idempotency-Key` header, paired with a checksum so the server can confirm a replay is the same payload (the streaming body is never buffered to compare). ```ts [Node] await s3.putObject("billing", "invoices/2026-001.json", body, { idempotencyKey: "invoice-2026-001", }); ``` The full conditional-write and idempotency model, including create-only and overwrite-only writes, is on the [conditional writes](/guide/conditional-writes) page. ## Related * [Conditional writes and idempotency](/guide/conditional-writes) for create-only writes and the idempotency key. * [Checksums](/guide/checksums) for the `BadDigest` failure path. * [Multipart uploads](/guide/multipart-uploads) for which multipart calls retry. * [S3 operations reference](/reference/s3-operations) for the full operation matrix. --- --- url: /sdks.md description: >- First-party Lockwell SDKs for Go, Node, and Java, plus the opt-in LNW/1 TypeScript, SolidStart, and Spring server integrations. --- # SDKs Lockwell ships first-party native-wire SDKs for Go, Node, and Java 25, plus source-shipped LNW/1 integrations for server-side TypeScript (Node/Bun), SolidStart v2, Next.js 16.3+, Nuxt 4.5+, and Spring Boot 4.1/JDK 25. Every SDK retains explicit S3, Admin, or HTTP/JSON compatibility surfaces where documented, and every transport enters the same encrypted, multi-tenant domain pipeline rather than creating a second store. ::: warning Release boundary The current public snippets describe source and historical private package coordinates. `v0.2.2` predates the selected PolyForm distribution payload and is not a current approved TangibleShift or commercial release; publication and commercial use remain behind B-010/B-013 and a written grant. ::: You build your object-storage layer on one explicitly selected transport. There is no second store and no implicit transport switching. ## Native Wire transport LNW/1 is a deterministic binary transport for the native data plane. It is experimental, opt-in, and disabled by default. The existing HTTP-native JSON client remains the supported default until a deployment completes the LNW qualification gates. The [Native Wire guide](/guide/native-wire) covers TLS 1.3/mTLS, capability negotiation, streaming, retry and failure semantics, and rollback. The currently merged LNW consumers are: | Integration | Runtime contract | Guide | | --- | --- | --- | | `@kelphect/sdk-native` 0.1.0 | Node 22+, Bun 1.4+; server-only | [Node/Bun guide](/sdks/bun-native) | | `@kelphect/sdk-solidstart` 0.1.0 | SolidStart v2; Node 22+ or Bun 1.4+ servers; Nitro `node_server`, `node_cluster`, `bun` | [SolidStart guide](/sdks/solidstart) | | `@kelphect/sdk-nextjs` 0.1.0 | Next.js 16.3.3 through 16.x; Node 22+ or Bun 1.4+ Node-compatible servers | [Next.js guide](/sdks/nextjs) | | `@kelphect/nuxt-lockwell` 0.1.0 | Nuxt 4.5+; Nitro `nitro-dev`, `node-server`, `node-cluster`, or `bun` | [Nuxt guide](/sdks/nuxt) | | `com.lockwell:lockwell-spring-boot-starter` 0.2.2 | JDK 25, Spring Boot 4.1.1; separate `--release 25` artifact | [Spring guide](/sdks/java-spring-wire) | These package coordinates are source/test contracts and may still be behind the approved publication gate. The standalone Go LNW client, Node primary-LNW transport, Nuxt, and Java 25 shared-core are integrated in the final candidate. The [capability index](/reference/sdk-capabilities) withholds the production-ready claim until the combined exact-head Oracle, cluster, benchmark, CI, and security gates pass. ## The three surfaces | Surface | Talks to | Auth | Use it for | | ------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | S3 client | the S3 listener (SigV4 + XML) | SigV4 on every request | A drop-in for AWS S3 clients, including query-SigV4 object presigning for GET, PUT, HEAD, and DELETE. | | Native wire client (Go) | the LNW/1 binary data plane on `native_wire.listen_addr` | TLS 1.3 plus per-handshake HMAC proof | Typed binary streaming, ranges, multipart, versions, Object Lock, CORS, notifications, and signed capabilities. | | Legacy native client | the native JSON data plane, `/api/v1/` on the public listener | a short-lived bearer token (`lwtk_…`) minted from an access key, auto-managed | Explicit HTTP/JSON compatibility for existing deployments. | | Admin client | the JSON Admin API, `/admin/api/v1/` on the admin listener | an admin API bearer token (`lwadm_…`) | Provisioning tenants, accounts, scoped keys, quotas, audit. | On top of those, the app kit (`LockwellKit`) composes the admin and native clients into the five jobs a multi-tenant app would otherwise hand-roll: * Provision a tenant plus a scoped key. * Get a per-tenant data client. * Configure browser CORS for signed URL fetches. * Mint browser-direct signed upload and download URLs. * Verify webhooks. ## Which one do I use? * Moving objects from an existing S3 codebase? Use the S3 client. It signs byte-for-byte like AWS and exposes query-SigV4 object presigners for GET, PUT, HEAD, and DELETE. All four helpers have implementation and offline method-binding coverage; the tracked Phase 2 quick matrix records separate passing live GET, PUT, HEAD, and DELETE rows for Go, Node, and Java. The quick profile deliberately excludes the 10/15 GiB scenarios. * Building a new server application? Use the language's LNW/1 client: `lockwellwire.Client`, Node's package-root `NativeClient`, or Java's `LockwellNativeWireClient`. Each uses TLS 1.3 and never falls back. Existing HTTP/JSON deployments use the explicitly named compatibility clients. Browser/edge code must use the separate [`@kelphect/sdk/edge`](/guide/edge-runtimes) JSON-safe entry; native-wire packages deny browser imports. * Provisioning tenants and keys? Use the admin client against the admin listener, never the public S3 port. * Building the whole multi-tenant integration? Use the [app kit](/guide/app-kit). It provisions tenants, hands you per-tenant clients, and mints browser signed URLs. See [The three surfaces](/guide/the-three-surfaces) for the conceptual tour. For transport/TLS/timeouts and the exact cross-language differences, read [Client configuration](/guide/client-configuration) and the [machine-checkable capability index](/reference/sdk-capabilities). Existing S3 applications should also read [Migration and compatibility](/guide/migration). ::: tip Need browser-direct uploads? The S3 client can presign PUT, while the native client can mint a method- and scope-bound signed PUT URL without SigV4. Choose the surface that matches the application's integration model. ::: ## Small, explicit dependency boundaries The core SDKs keep their platform dependencies narrow and make framework dependencies explicit. * Go uses the standard library only (`net/http`, `crypto/*`, `encoding/*`). * Node's S3 and JSON compatibility surfaces use platform APIs. The server-only native client has the explicit `@kelphect/sdk-native` peer; the dedicated edge entry excludes it and remains `node:*`-free. See [Edge runtimes](/guide/edge-runtimes). * Java's SDK uses the JDK plus optional Micrometer/OpenTelemetry integration. The Spring Boot starter declares its Boot 4.1 dependencies and delegates the wire implementation to the core Java 25 SDK. The Go, Node, and Java S3 clients share a language-neutral set of SigV4 signing fixtures, so all three sign byte-for-byte identically. All three clients also preserve a clean reverse-proxy endpoint prefix in normal requests, native/admin routes, SigV4 canonical paths, and presigned URLs. An endpoint that already includes `/api/v1` or `/admin/api/v1` is normalized once; traversal and encoded-separator paths fail closed. See the language pages for transport timeout and cancellation options. This is deterministic SDK evidence; it does not claim hosted or multi-node deployment proof. ## Leaner than the AWS SDK Lockwell's clients spend less client CPU per request than the AWS S3 SDK in the same language. The native (bearer + JSON) client is the leanest of all: it mints its bearer token once and reuses it, skipping per-request SigV4 canonicalization and HMAC key derivation. The numbers from the client-overhead micro-benchmarks: * The Go S3 client allocates roughly 30-40% fewer objects per PUT/GET than `aws-sdk-go-v2`. * The Node and Java native clients beat `@aws-sdk/client-s3` and `software.amazon.awssdk:s3` on every **measured** operation in the cited client-overhead microbenchmark. * The widest margin is on list responses, where the AWS SDK's XML deserializer is the expensive part. These measure client overhead only, not end-to-end storage or provider-replacement performance. The full methodology, including the loopback/mock setup and Java HTTP-client caveat, lives in [the SDK benchmarks](https://github.com/RusticStack/lockwell/blob/main/docs/sdk-benchmarks.md). ## Language references * [Go SDK](/sdks/go) covers `pkg/lockwellsdk`, `pkg/lockwellwire`, legacy `pkg/lockwellnative`, `pkg/lockwelladmin`, and `pkg/lockwellkit`. * [Node SDK](/sdks/node) covers `@kelphect/sdk` and `@kelphect/sdk/edge`. * [Java SDK](/sdks/java) covers `com.lockwell:lockwell-sdk` and the Spring Boot starter. * [Java native client](/sdks/java-native) covers HTTP-native JSON retry, timeout, and idempotency settings for JVM services. * [Native Wire guide](/guide/native-wire) explains the transport and its exact limits. * [Spring Boot Native Wire](/sdks/java-spring-wire) covers the JDK 25 starter and TangibleShift-shaped server pattern. * [Native TypeScript](/sdks/bun-native) and [SolidStart v2](/sdks/solidstart) cover the merged server integrations. * [Next.js 16.3](/sdks/nextjs) covers the merged App Router adapter and Node-compatible deployment modes. * [Nuxt 4.5+](/sdks/nuxt) covers the server-only module, allowed Nitro presets, streaming helpers, and fail-closed deployment boundaries. ## Reference pages * [S3 operations reference](/reference/s3-operations) is the full operation matrix shared by all three S3 clients. * [Native data-plane API](/reference/native-api) is the JSON `/api/v1/` wire contract. * [Native Wire protocol](/reference/native-wire) is the binary LNW/1 envelope, registry, and error reference. * [Admin API](/reference/admin-api) is the JSON `/admin/api/v1/` wire contract. * [SDK capability index](/reference/sdk-capabilities) maps shipped capabilities across all three languages and links to `/sdk-capabilities.json` and `/sdk-public-api-examples-v1.json` for tools and AI agents. --- --- url: /sdks/go.md description: >- The first-party Lockwell SDK for Go, split into five standard-library-only packages for the S3, LNW/1 native-wire, legacy native JSON, admin, and app-kit surfaces. --- # Go SDK The first-party Lockwell SDK for Go, split into five packages. Each is a separate import, so you pull in only the surface you need. ::: warning Historical package line The `v0.2.2` module coordinate below is a historical private tag, not a current approved commercial release. Do not use it for TangibleShift or another commercial deployment until B-010/B-013 clear and the required written grant is in place. ::: The non-test source imports only the Go standard library. A consumer gets a light dependency tree and never reaches into server internals. ```sh go env -w GOPRIVATE=github.com/KelpHect/* go get github.com/KelpHect/lockwell@v0.2.2 ``` ```go import ( "github.com/KelpHect/lockwell/pkg/lockwellsdk" // S3 (SigV4) data plane "github.com/KelpHect/lockwell/pkg/lockwellwire" // LNW/1 binary native data plane "github.com/KelpHect/lockwell/pkg/lockwellnative" // native JSON data plane "github.com/KelpHect/lockwell/pkg/lockwelladmin" // JSON admin API "github.com/KelpHect/lockwell/pkg/lockwellkit" // the app kit ) ``` > `github.com/KelpHect/lockwell` is the retained `v0.x` compatibility module path. The canonical repository is > [`RusticStack/lockwell`](https://github.com/RusticStack/lockwell); GitHub's repository-transfer redirect keeps the > established imports resolvable. Set `GOPRIVATE=github.com/KelpHect/*` and authenticate GitHub before `go get`. Every client is safe for concurrent use by multiple goroutines. Every client redacts its secret on `String()` and `%#v`, so it can never be logged with credential material. ## `pkg/lockwellsdk` (the S3 client) A SigV4 S3 client. Buffered writes sign the exact SHA-256 of the body and are eligible for idempotent retry. Streaming writes use the documented streaming-chunk or `UNSIGNED-PAYLOAD` forms and are not transparently replayed. ```go ctx := context.Background() c, err := lockwellsdk.New( "https://objects.example.com", lockwellsdk.Credentials{ AccessKeyID: os.Getenv("LOCKWELL_ACCESS_KEY_ID"), SecretKey: os.Getenv("LOCKWELL_SECRET_KEY"), }, ) if err != nil { log.Fatal(err) } // Private bucket (Lockwell exposes no way to make one public). if err := c.CreateBucket(ctx, "reports"); err != nil { log.Fatal(err) } // One PUT with SSE-S3 at rest, a server-verified CRC64NVME checksum, and an // idempotency key for safe retry. put, err := c.PutObject(ctx, "reports", "q1.txt", []byte("hello"), lockwellsdk.WithContentType("text/plain"), lockwellsdk.WithServerSideEncryption(), lockwellsdk.WithChecksumAlgorithm(lockwellsdk.ChecksumCRC64NVME), lockwellsdk.WithIdempotencyKey("q1-2026"), ) fmt.Println(put.ETag, put.VersionID) // GetObject streams; the caller owns Body and must close it. out, err := c.GetObject(ctx, "reports", "q1.txt") if err != nil { if lockwellsdk.IsNotFound(err) { /* missing key */ } log.Fatal(err) } defer out.Body.Close() io.Copy(os.Stdout, out.Body) ``` 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 is included in SigV4 canonicalization. Already-mounted `/api/v1` and `/admin/api/v1` suffixes are normalized exactly once; traversal, encoded separators, malformed escapes, and unsafe interior path segments are rejected. For an endpoint configured with wildcard bucket DNS, pass `lockwellsdk.WithVirtualHostedStyle()` to put the bucket in the signed host for both normal requests and presigned URLs; dotted bucket names are preserved. ### Construction Construct the client with `New(endpoint string, creds Credentials, opts ...Option) (*Client, error)`. The endpoint scheme must be `http` or `https`. Path-style is the default; pass `WithVirtualHostedStyle()` for an endpoint with wildcard bucket DNS. That option moves the bucket, including dotted bucket names, into the signed host for normal and presigned requests. | Option | Effect | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | | `WithHTTPClient(*http.Client)` | Control timeouts, transport pooling, or TLS. The default client has no timeout, so set one here or always pass a context with a deadline. | | `WithRequestTimeout(Duration)` | Bound each individual HTTP attempt; retries receive a fresh budget, while the caller context remains the overall deadline. | | `WithUserAgent(string)` | Override the `User-Agent` header. | | `WithRetryPolicy(RetryPolicy)` | Override automatic retry. The default retries safe and idempotent requests; pass `DisabledRetryPolicy()` to turn it off. | | `WithRegion(string)` | Select the server-configured SigV4 region (default `us-east-1`). | | `WithResponseMetadata(func)` | Observe successful request-id, Amazon request-id, and `traceparent` headers. | | `WithVirtualHostedStyle()` | Put the bucket in the signed host when wildcard DNS is configured. | ### Buckets | Method | Signature | | --------------------- | ------------------------------------------------------------------------ | | `CreateBucket` | `CreateBucket(ctx, bucket string, opts ...BucketOption) error` | | `HeadBucket` | `HeadBucket(ctx, bucket string) error` | | `DeleteBucket` | `DeleteBucket(ctx, bucket string) error` | | `PutBucketVersioning` | `PutBucketVersioning(ctx, bucket string, status VersioningStatus) error` | | `GetBucketVersioning` | `GetBucketVersioning(ctx, bucket string) (VersioningStatus, error)` | `WithObjectLockEnabled(mode ObjectLockMode, days int)` is the only `BucketOption`. It enables Object Lock (and the versioning it requires) at create time with a default retention rule. Lockwell requires a default retention when enabling lock at create, so pass a mode (`ObjectLockGovernance` or `ObjectLockCompliance`) and a positive day count. `VersioningStatus` is `VersioningEnabled` or `VersioningSuspended`; `GetBucketVersioning` returns `""` when versioning was never enabled. ```go err := c.CreateBucket(ctx, "vault", lockwellsdk.WithObjectLockEnabled(lockwellsdk.ObjectLockCompliance, 30)) ``` ### Objects | Method | Signature | | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `PutObject` | `PutObject(ctx, bucket, key string, body []byte, opts ...PutOption) (*PutObjectResult, error)` | | `PutObjectStream` | `PutObjectStream(ctx, bucket, key string, r io.Reader, size int64, checksum ChecksumAlgorithm, opts ...PutOption) (*PutObjectResult, error)` | | `GetObject` | `GetObject(ctx, bucket, key string, opts ...GetOption) (*GetObjectOutput, error)` | | `HeadObject` | `HeadObject(ctx, bucket, key string, opts ...GetOption) (*HeadObjectOutput, error)` | | `DeleteObject` | `DeleteObject(ctx, bucket, key string, opts ...GetOption) error` | | `DeleteObjects` | `DeleteObjects(ctx, bucket string, objects []ObjectIdentifier, opts ...DeleteObjectsOption) (*DeleteObjectsOutput, error)` | | `CopyObject` | `CopyObject(ctx, srcBucket, srcKey, srcVersionID, dstBucket, dstKey string, opts ...CopyOption) (*CopyObjectOutput, error)` | `GetObjectOutput.Body` is an `io.ReadCloser` you must close. A successful `GetObject` transfers body ownership to you; the error path drains and closes it for you. #### `PutObject` options | Option | Effect | | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `WithContentType(ct string)` | Sets `Content-Type`. | | `WithMetadata(m map[string]string)` | User metadata, stored and returned as `x-amz-meta-*`. | | `WithIdempotencyKey(key string)` | Makes the write idempotent. A retry with the same key and identical request returns the original result instead of writing twice. The SDK signs the `X-Lockwell-Idempotency-Key` header so it cannot be stripped in transit. | | `WithChecksumAlgorithm(a ChecksumAlgorithm)` | Server computes, verifies, and persists an end-to-end checksum. The digest is returned on the result and can be demanded on later reads. | | `WithServerSideEncryption()` | Requests SSE-S3 (server-managed, per-tenant key) at rest. | | `WithObjectLockRetention(mode ObjectLockMode, retainUntil time.Time)` | Applies a retention mode and retain-until date on PUT. The bucket must have Object Lock enabled. | | `WithObjectLockLegalHold(on bool)` | Places or clears a legal hold on PUT. | | `WithPutIfNoneMatch("*")` | Atomic create-only write; returns a typed 412 error when the key already exists. | | `WithPutSSECustomerKey(key []byte)` | Supplies a raw 32-byte SSE-C key; copied into request-local state and never persisted by the SDK. | | `WithProgress(fn)` / `WithPutProgress(fn)` | Synchronous, backpressure-safe progress callback; returning an error cancels the request. | `PutObjectResult` carries `ETag`, `VersionID`, `Checksums`, and `ServerSideEncryption`. > The S3 `PutObject` supports create-only `If-None-Match: *` through `WithPutIfNoneMatch("*")`. Overwrite-only > `If-Match` is available on the [native client](#pkg-lockwellnative-the-native-client); S3 copy-source conditionals are > listed below. #### `GetObject` / `HeadObject` options | Option | Effect | | ------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `WithRange(start, end int64)` | Bytes `[start, end]` inclusive; pass `end < 0` for "to end". | | `WithPartNumber(n int)` | Returns the byte range of one multipart part (1-based) with a 206 and the total part count. | | `WithVersionID(id string)` | Targets a specific object version. Also accepted by `DeleteObject`, tagging, and Object Lock reads. | | `WithResponseContentType(ct string)` | The `response-content-type` override on this read. | | `WithResponseContentDisposition(cd string)` | The `response-content-disposition` override. | | `WithIfMatch(etag string)` | Return the object only when its current ETag matches. | | `WithIfNoneMatch(etag string)` | Return the object only when its current ETag differs. | | `WithIfModifiedSince(httpDate string)` | Return the object only when it changed after the HTTP date. | | `WithIfUnmodifiedSince(httpDate string)` | Return the object only when it did not change after the HTTP date. | | `WithReadSSECustomerKey(key []byte)` | Supply the raw 32-byte key for an SSE-C object. | | `WithDownloadProgress(fn)` | Report streamed download progress; callback failure cancels/closes the body. | #### Streaming uploads `PutObjectStream` streams a body without buffering the whole object. The checksum is computed incrementally and sent in an `aws-chunked` trailer, so `checksum` is required. `WithIdempotencyKey` is not supported here, because the trailing checksum is not known at reservation time; use `PutObject` for idempotent writes. Streaming bodies are never auto-retried, since the reader is already consumed. The unsigned streaming-trailer variant requires the server to permit unsigned payloads, which is the default. Streaming PUT/GET and multipart part APIs also accept synchronous `ProgressFunc` callbacks. Updates report cumulative bytes and the known total (or `lockwellsdk.UnknownTotal`); callback execution is part of the read path, so a slow callback applies backpressure and no whole-object progress buffer is created. Returning an error cancels the request and closes the stream. Use `WithResponseMetadata` to observe successful `X-Request-Id`, optional `X-Amz-Request-Id`, and optional `traceparent` headers without changing the existing result types. The concrete helpers are `GetObjectWithProgress`, `WithProgress` / `WithPutProgress`, `WithGetProgress` / `WithGetObjectProgress` / `WithDownloadProgress`, and `WithPartProgress` / `WithMultipartProgress` / `WithUploadPartProgress`. `Progress.TotalKnown()` and `Progress.Complete()` interpret `UnknownTotal` without guessing. When progress is attached to buffered `PutObject` or `UploadPart`, Go uses a one-pass body so callback cancellation is immediate; automatic replay is disabled even with an idempotency key. If progress and retries are both needed, own the replay loop and reuse the same body-binding checksum and key. ```go f, _ := os.Open("big.bin") defer f.Close() info, _ := f.Stat() res, err := c.PutObjectStream(ctx, "reports", "big.bin", f, info.Size(), lockwellsdk.ChecksumCRC64NVME, lockwellsdk.WithContentType("application/octet-stream")) ``` #### Batch delete `DeleteObjects` deletes up to 1000 objects in one `POST /{bucket}?delete`. Pass an `ObjectIdentifier{Key, VersionID}` per key; set `VersionID` to delete a specific version. The batch may partially succeed, so inspect `Output.Deleted` and `Output.Errors`. `WithQuietDelete()` suppresses the per-key `Deleted` entries (errors are always returned). The SDK rejects an oversized batch locally before the request. ```go res, err := c.DeleteObjects(ctx, "reports", []lockwellsdk.ObjectIdentifier{ {Key: "old/a.txt"}, {Key: "old/b.txt", VersionID: "v2"}, }) for _, e := range res.Errors { log.Printf("could not delete %s: %s", e.Key, e.Message) } ``` #### Copy `CopyObject` copies server-side. Pass `srcVersionID` to copy a specific version (`""` copies the current version). Copy options: | Option | Effect | | ----------------------------------------------- | ------------------------------------------------------------------ | | `WithCopyServerSideEncryption()` | SSE-S3 for the destination. | | `WithCopyMetadata(m map[string]string)` | Replace destination metadata (default copies the source metadata). | | `WithCopyIfMatch(etag string)` | Copy only if the source ETag matches. | | `WithCopyIfNoneMatch(etag string)` | Copy only if the source ETag does not match. | | `WithCopyIfModifiedSince(httpDate string)` | Copy only if the source changed since this HTTP date. | | `WithCopyIfUnmodifiedSince(httpDate string)` | Copy only if the source has not changed since this HTTP date. | | `WithCopySourceSSECustomerKey(key []byte)` | Decrypt an SSE-C source. | | `WithCopyDestinationSSECustomerKey(key []byte)` | Encrypt the destination with SSE-C. | ### Listing | Method | Signature | | ---------------------- | ---------------------------------------------------------------------------------------------------------- | | `ListObjectsV2` | `ListObjectsV2(ctx, bucket string, opts ...ListOption) (*ListObjectsV2Output, error)` | | `ListObjects` | `ListObjects(ctx, bucket string, opts ...ListOption) (*ListObjectsOutput, error)` | | `ListObjectVersions` | `ListObjectVersions(ctx, bucket string, opts ...ListVersionsOption) (*ListObjectVersionsOutput, error)` | | `ListMultipartUploads` | `ListMultipartUploads(ctx, bucket string, opts ...ListUploadsOption) (*ListMultipartUploadsOutput, error)` | | `ListParts` | `ListParts(ctx, bucket, key, uploadID string, opts ...ListPartsOption) (*ListPartsOutput, error)` | `ListObjectsV2` options: `WithPrefix`, `WithDelimiter`, `WithStartAfter`, `WithContinuationToken`, `WithMaxKeys`. `ListObjects` (v1) uses `WithMarker` instead of `WithStartAfter`/`WithContinuationToken`. Prefer V2 for new code; v1 exists for parity with clients that page by marker. `ListObjectVersions` returns both `Versions` and `DeleteMarkers`, paged with `WithKeyMarker(NextKeyMarker)` plus `WithVersionIDMarker(NextVersionIDMarker)`. Its options also include `WithVersionsPrefix`, `WithVersionsDelimiter`, and `WithVersionsMaxKeys`. ### Paginators Every page-based list has an auto-pager that threads continuation and marker tokens for you. Construct it with the client, bucket, and the same options the one-shot method takes, then loop on `HasMorePages()` / `NextPage(ctx)`. ```go p := c.NewListObjectsV2Paginator("reports", lockwellsdk.WithPrefix("logs/")) for p.HasMorePages() { page, err := p.NextPage(ctx) if err != nil { log.Fatal(err) } for _, obj := range page.Objects { fmt.Println(obj.Key, obj.Size) } } ``` The four constructors are `NewListObjectsV2Paginator`, `NewListObjectVersionsPaginator`, `NewListMultipartUploadsPaginator`, and `NewListPartsPaginator`. Do not also pass the marker or continuation options by hand; the paginator owns them. ### Multipart | Method | Signature | | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CreateMultipartUpload` | `CreateMultipartUpload(ctx, bucket, key string, opts ...PutOption) (*CreateMultipartUploadOutput, error)` | | `UploadPart` | `UploadPart(ctx, bucket, key, uploadID string, partNumber int, body []byte, opts ...UploadPartOption) (*UploadPartOutput, error)` | | `UploadPartStream` | `UploadPartStream(ctx, bucket, key, uploadID string, partNumber int, r io.Reader, size int64, opts ...UploadPartOption) (*UploadPartOutput, error)` | | `UploadPartCopy` | `UploadPartCopy(ctx, srcBucket, srcKey, srcVersionID, dstBucket, dstKey, uploadID string, partNumber int, byteRange string, opts ...UploadPartCopyOption) (*UploadPartCopyOutput, error)` | | `CompleteMultipartUpload` | `CompleteMultipartUpload(ctx, bucket, key, uploadID string, parts []CompletedPart, opts ...PutOption) (*CompleteMultipartUploadOutput, error)` | | `AbortMultipartUpload` | `AbortMultipartUpload(ctx, bucket, key, uploadID string) error` | When you create the upload with `WithChecksumAlgorithm`, pass the returned `ChecksumAlgorithm` to each `UploadPart` via `WithPartChecksum(alg)`. Every part then carries a verified per-part digest, and the composite checksum comes back on `CompleteMultipartUpload`. For SSE-C, pass the same key through `WithPutSSECustomerKey`, `WithPartSSECustomerKey`, and `WithCompleteSSECustomerKey`. Copy parts use `WithPartCopySourceSSECustomerKey` and `WithPartCopyDestinationSSECustomerKey`. `WithPartProgress`, `WithMultipartProgress`, and `WithUploadPartProgress` are equivalent progress aliases. ```go mpu, _ := c.CreateMultipartUpload(ctx, "reports", "big.bin", lockwellsdk.WithChecksumAlgorithm(lockwellsdk.ChecksumCRC32C)) p1, _ := c.UploadPart(ctx, "reports", "big.bin", mpu.UploadID, 1, part1, lockwellsdk.WithPartChecksum(mpu.ChecksumAlgorithm)) p2, _ := c.UploadPart(ctx, "reports", "big.bin", mpu.UploadID, 2, part2, lockwellsdk.WithPartChecksum(mpu.ChecksumAlgorithm)) done, _ := c.CompleteMultipartUpload(ctx, "reports", "big.bin", mpu.UploadID, []lockwellsdk.CompletedPart{ {PartNumber: 1, ETag: p1.ETag}, {PartNumber: 2, ETag: p2.ETag}, }) fmt.Println(done.ETag, done.Checksums.CRC32C) ``` ### Tagging and Object Lock | Method | Signature | | --------------------- | ------------------------------------------------------------------------------------------------- | | `PutObjectTagging` | `PutObjectTagging(ctx, bucket, key string, tags map[string]string, opts ...GetOption) error` | | `GetObjectTagging` | `GetObjectTagging(ctx, bucket, key string, opts ...GetOption) (map[string]string, error)` | | `DeleteObjectTagging` | `DeleteObjectTagging(ctx, bucket, key string, opts ...GetOption) error` | | `SetObjectRetention` | `SetObjectRetention(ctx, bucket, key string, retention ObjectRetention, opts ...GetOption) error` | | `PutObjectRetention` | `PutObjectRetention(ctx, bucket, key string, retention ObjectRetention, opts ...GetOption) error` | | `GetObjectRetention` | `GetObjectRetention(ctx, bucket, key string, opts ...GetOption) (*ObjectRetention, error)` | | `SetObjectLegalHold` | `SetObjectLegalHold(ctx, bucket, key string, on bool, opts ...GetOption) error` | | `PutObjectLegalHold` | `PutObjectLegalHold(ctx, bucket, key string, on bool, opts ...GetOption) error` | | `GetObjectLegalHold` | `GetObjectLegalHold(ctx, bucket, key string, opts ...GetOption) (bool, error)` | Retention and legal hold can be set at write time through `WithObjectLockRetention` and `WithObjectLockLegalHold` (see `PutObject` options), or changed after a write with `SetObjectRetention` and `SetObjectLegalHold`. The `Put...` methods are equivalent S3-operation-named aliases. Post-write retention takes an `ObjectRetention` with an `ObjectLockGovernance` or `ObjectLockCompliance` mode and a future `time.Time`; legal hold takes a boolean. Pass `WithVersionID` to target a specific version in every Object Lock operation. ```go until := time.Now().Add(30 * 24 * time.Hour) err := c.SetObjectRetention(ctx, "vault", "ledger.json", lockwellsdk.ObjectRetention{ Mode: lockwellsdk.ObjectLockCompliance, RetainUntilDate: until, }, lockwellsdk.WithVersionID(versionID)) if err != nil { /* handle typed S3 API error */ } err = c.SetObjectLegalHold(ctx, "vault", "ledger.json", true, lockwellsdk.WithVersionID(versionID)) ``` ### Presigned object URLs `PresignGetObject`, `PresignPutObject`, `PresignHeadObject`, and `PresignDeleteObject` return time-limited query-SigV4 object URLs matching the server's supported methods. `PresignGetObject` accepts `GetOption` values for version and `response-*` overrides. `WithVersionID` and the `response-*` overrides are folded into the signature; range and part-number options are ignored. The server enforces its own maximum TTL and rejects anything longer. ::: info The four S3 helpers have implementation and offline method-binding coverage. The tracked Phase 2 quick matrix also contains separate passing live GET, PUT, HEAD, and DELETE rows for Go, Node, and Java, including object-state checks and wrong-method denials. The quick profile deliberately excludes the 10/15 GiB scenarios. ::: ```go url, err := c.PresignGetObject("reports", "q1.txt", 15*time.Minute) ``` For a native signed write URL, use the native client's [`SignURL`](#signed-urls-get-and-put). ### Checksums `ChecksumAlgorithm` is one of `ChecksumCRC32`, `ChecksumCRC32C`, `ChecksumCRC64NVME`, `ChecksumSHA1`, `ChecksumSHA256`. The SDK computes the digest client-side from the standard library and sends a precomputed `x-amz-checksum-` header the server validates against the body it received. `Checksums` on a result carries the base64 digest for whichever algorithm the server echoed. ### Retry By default a `Client` uses `DefaultRetryPolicy()`: up to 3 attempts, 100ms base backoff doubling to a 2s cap, with full jitter. It retries GET/HEAD/DELETE and any PUT/POST that carries an idempotency key, on transport errors and on 5xx/429 responses. A 4xx other than 429 is never retried. Streaming bodies are never retried. ```go // Tune it: c, _ := lockwellsdk.New(endpoint, creds, lockwellsdk.WithRetryPolicy(lockwellsdk.RetryPolicy{ MaxAttempts: 5, BaseBackoff: 200 * time.Millisecond, MaxBackoff: 4 * time.Second, Jitter: 1.0, })) // Or turn it off: c, _ = lockwellsdk.New(endpoint, creds, lockwellsdk.WithRetryPolicy(lockwellsdk.DisabledRetryPolicy())) ``` ### Errors Server errors are `*lockwellsdk.APIError` with `Code`, `Message`, `StatusCode`, and `RequestID` (for support correlation). Use `lockwellsdk.IsNotFound(err)` for a missing bucket, key, or upload. ```go out, err := c.GetObject(ctx, "reports", "missing.txt") if lockwellsdk.IsNotFound(err) { // 404 / NoSuchKey / NoSuchBucket / NoSuchUpload } var apiErr *lockwellsdk.APIError if errors.As(err, &apiErr) { log.Printf("code=%s status=%d requestId=%s", apiErr.Code, apiErr.StatusCode, apiErr.RequestID) } ``` ## `pkg/lockwellwire` (the LNW/1 native-wire client) Production Go native data-plane integrations use `pkg/lockwellwire`, which speaks the binary LNW/1 protocol directly over the separately configured native wire listener. It requires TLS 1.3 and normal hostname/certificate verification, supports mTLS and additive certificate pins, refreshes credentials for every new handshake, and provides typed streaming, ranges, multipart, versions, Object Lock, CORS, notifications, and signed-capability operations. ```go wire, err := lockwellwire.New(lockwellwire.Config{ Address: "objects.example.com:9443", Credentials: lockwellwire.Credentials{ AccessKeyID: os.Getenv("LOCKWELL_ACCESS_KEY_ID"), SecretKey: os.Getenv("LOCKWELL_SECRET_KEY"), }, }) if err != nil { log.Fatal(err) } defer wire.Close() object, err := wire.GetObject(ctx, lockwellwire.GetObjectInput{ Bucket: "reports", Key: "q1.txt", }) if err != nil { log.Fatal(err) } defer object.Body.Close() _, _ = io.Copy(os.Stdout, object.Body) ``` The wire client never emits HTTP, JSON, XML, or S3 signatures. Context cancellation closes streaming bodies and closeable upload producers. Callers own retries; `*lockwellwire.Error` exposes stable codes and bounded retry-after metadata, and writes should use an idempotency key plus a replayable source when the caller chooses to retry. `Client.Ping` is available for an authenticated PING/PONG liveness check that does not create an application stream. ## `pkg/lockwellnative` (legacy HTTP/JSON compatibility client) Talks to the native JSON data plane at `/api/v1/`. No SigV4, no XML. This package remains only for migration compatibility. `New` is deprecated; use `NewHTTPCompatibility` to make the legacy transport explicit. It does not provide LNW/1 guarantees. See the [`Go LNW/1 migration guide`](https://github.com/RusticStack/lockwell/blob/main/docs/sdk-go-native-wire-migration.md). It mints a short-lived bearer token from your access key on first use, caches it until shortly before expiry, refreshes transparently, and re-mints once on a 401. Token management is thread-safe with single-flight refresh, so a burst of concurrent requests mints at most one token. ```go nc, err := lockwellnative.NewHTTPCompatibility( "https://objects.example.com", // public listener; /api/v1 is mounted automatically os.Getenv("LOCKWELL_ACCESS_KEY_ID"), os.Getenv("LOCKWELL_SECRET_KEY"), ) if err != nil { log.Fatal(err) } // Streaming PUT. The body is streamed, never whole-object buffered. res, err := nc.PutObject(ctx, lockwellnative.PutObjectInput{ Bucket: "reports", Key: "q1.txt", Body: strings.NewReader("hello"), ContentType: "text/plain", IdempotencyKey: "q1-2026", Checksums: map[string]string{"sha256": sha256Base64}, }) fmt.Println(res.ETag, res.VersionID) // Streaming GET. Read the body and Close it. obj, err := nc.GetObject(ctx, lockwellnative.GetObjectInput{Bucket: "reports", Key: "q1.txt"}) if err != nil { if lockwellnative.IsNotFound(err) { /* missing key */ } log.Fatal(err) } defer obj.Close() io.Copy(os.Stdout, obj) ``` `New` takes `WithHTTPClient` and `WithUserAgent` options, the same as the S3 client. The streaming PUT is safe across a token refresh. Because the body cannot be replayed, `PutObject` proactively ensures a fresh, unexpired token before streaming rather than relying on a 401-retry. ### Buckets | Method | Signature | | --------------------- | --------------------------------------------------------------------------- | | `ListBuckets` | `ListBuckets(ctx) ([]Bucket, error)` | | `CreateBucket` | `CreateBucket(ctx, in CreateBucketInput) (*Bucket, error)` | | `GetBucket` | `GetBucket(ctx, bucket string) (*Bucket, error)` | | `DeleteBucket` | `DeleteBucket(ctx, bucket string) error` | | `GetBucketVersioning` | `GetBucketVersioning(ctx, bucket string) (*VersioningState, error)` | | `SetBucketVersioning` | `SetBucketVersioning(ctx, bucket, status string) (*VersioningState, error)` | `CreateBucketInput{Name, Versioning, ObjectLockEnabled}` is private by design (there is no public option). Versioning may be set here, and Object Lock can only be enabled at create time. `SetBucketVersioning` takes `"enabled"` or `"suspended"`. A create-on-existing returns a `NativeError` with 409 (`IsAlreadyExists`). ### Objects | Method | Signature | | -------------------- | ------------------------------------------------------------------------------------------- | | `PutObject` | `PutObject(ctx, in PutObjectInput) (*PutObjectResult, error)` | | `GetObject` | `GetObject(ctx, in GetObjectInput) (*ObjectReader, error)` | | `HeadObject` | `HeadObject(ctx, in GetObjectInput) (*ObjectInfo, error)` | | `DeleteObject` | `DeleteObject(ctx, bucket, key, versionID string) (*DeleteObjectResult, error)` | | `ListObjects` | `ListObjects(ctx, in ListObjectsInput) (*ListObjectsResult, error)` | | `ListObjectsAll` | `ListObjectsAll(ctx, in ListObjectsInput) *ObjectIterator` | | `BatchDeleteObjects` | `BatchDeleteObjects(ctx, bucket string, keys []BatchDeleteKey) (*BatchDeleteResult, error)` | | `CopyObject` | `CopyObject(ctx, in CopyObjectInput) (*CopyObjectResult, error)` | `PutObjectInput` fields: | Field | Effect | | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | `Body io.Reader` | Streamed with no whole-object buffering. | | `ContentType` | Stored media type (default `application/octet-stream`). | | `ContentLength int64` | When > 0, sets `Content-Length` so the server enforces the size cap and quota up front; otherwise the body is sent chunked. | | `IdempotencyKey` | The same key replays the stored result instead of writing twice. | | `IfNoneMatch: "*"` | Create only when the key is absent (412 otherwise). | | `IfMatch: ""` | Overwrite only when the current ETag matches (412 otherwise). | | `Checksums map[string]string` | Algorithm (`"sha256"`, `"crc32c"`, ...) to expected base64 digest. A bad digest is rejected before any bytes are committed. | | `Metadata map[string]string` | User metadata, stored as `X-Lockwell-Meta-*`. | `GetObjectInput{Bucket, Key, VersionID, Range}` drives both `GetObject` and `HeadObject`. `ObjectReader` embeds `io.ReadCloser` and surfaces native fields: `ContentType`, `ContentLength`, `ETag`, `VersionID`, `ContentRange`, `Checksums`, `Encrypted`, `StorageClass`, `LegalHold`, `RetainUntil`. `ListObjectsAll` returns an `*ObjectIterator` that follows continuation tokens for you: ```go it := nc.ListObjectsAll(ctx, lockwellnative.ListObjectsInput{Bucket: "reports", Prefix: "logs/"}) for it.Next() { obj := it.Object() fmt.Println(obj.Key, obj.Size) } if err := it.Err(); err != nil { log.Fatal(err) } ``` `CopyObjectInput` carries the destination `Bucket`/`Key` plus `SourceBucket`, `SourceKey`, `SourceVersionID`, `MetadataDirective` (`"COPY"` default or `"REPLACE"`), `ContentType`, `Metadata`, the source conditionals (`IfMatch`, `IfNoneMatch`, `IfModifiedSince`, `IfUnmodifiedSince`), and the destination preconditions (`RequireAbsent`, `RequireMatchETag`). Cross-tenant copy is impossible, since the source resolves under the token's tenant. ### Tags, retention, legal hold, versions | Method | Signature | | -------------------- | ---------------------------------------------------------------------------------------- | | `GetObjectTags` | `GetObjectTags(ctx, bucket, key string) ([]Tag, error)` | | `SetObjectTags` | `SetObjectTags(ctx, bucket, key string, tags []Tag) ([]Tag, error)` | | `DeleteObjectTags` | `DeleteObjectTags(ctx, bucket, key string) error` | | `GetObjectRetention` | `GetObjectRetention(ctx, bucket, key string) (*Retention, error)` | | `SetObjectRetention` | `SetObjectRetention(ctx, bucket, key, mode, retainUntil string) (*Retention, error)` | | `GetObjectLegalHold` | `GetObjectLegalHold(ctx, bucket, key string) (*LegalHold, error)` | | `SetObjectLegalHold` | `SetObjectLegalHold(ctx, bucket, key, status string) (*LegalHold, error)` | | `ListObjectVersions` | `ListObjectVersions(ctx, in ListObjectVersionsInput) (*ListObjectVersionsResult, error)` | Both clients can set retention and legal hold after a write; the native client uses JSON-native result types. `mode` is `"GOVERNANCE"` or `"COMPLIANCE"` and `retainUntil` is RFC3339; `status` is `"ON"` or `"OFF"`. ::: warning The server enforces the same WORM gate as the S3 path. There is no governance bypass on the native path. ::: ### Multipart | Method | Signature | | ------------------------- | -------------------------------------------------------------------------------------- | | `CreateMultipartUpload` | `CreateMultipartUpload(ctx, bucket, key string) (*MultipartUpload, error)` | | `UploadPart` | `UploadPart(ctx, in UploadPartInput) (*UploadedPart, error)` | | `ListParts` | `ListParts(ctx, bucket, key, uploadID string) (*PartListing, error)` | | `CompleteMultipartUpload` | `CompleteMultipartUpload(ctx, in CompleteMultipartInput) (*CompletedMultipart, error)` | | `AbortMultipartUpload` | `AbortMultipartUpload(ctx, bucket, key, uploadID string) error` | | `ListMultipartUploads` | `ListMultipartUploads(ctx, bucket string) (*MultipartUploadListing, error)` | `UploadPartInput{Bucket, Key, UploadID, PartNumber, Body, ContentLength}` streams the part body. `CompleteMultipartInput{Bucket, Key, UploadID, Parts, IfNoneMatch, IfMatch}` gates the completed object atomically at the commit. `Parts` is a required ordered `[]CompleteMultipartPart` manifest; the server validates each referenced part and assembles exactly that selection. ### Bucket CORS The native client exposes browser CORS as JSON structs over the same server-side validator as S3 `?cors`: ```go cfg := lockwellnative.CORSConfiguration{ Rules: []lockwellnative.CORSRule{{ AllowedOrigins: []string{"https://app.example.com"}, AllowedMethods: []string{"GET", "HEAD", "PUT"}, AllowedHeaders: []string{"content-type"}, ExposeHeaders: []string{"ETag"}, MaxAgeSeconds: 600, }}, } stored, err := nc.SetBucketCORS(ctx, "reports", cfg) got, err := nc.GetBucketCORS(ctx, "reports") err = nc.DeleteBucketCORS(ctx, "reports") ``` Changing CORS is an admin-scoped bucket operation. For app onboarding, prefer `lockwellkit.ConfigureBucketCORS` or `ProvisionTenantInput.DefaultBucketCORS`, which use a transient admin key and revoke it after the update. ### Signed URLs (GET and PUT) Unlike the S3 presigner, the native API supports signed write URLs. ```go // A browser-usable upload URL that needs NO bearer token. upload, err := nc.SignURL(ctx, lockwellnative.SignURLInput{ Method: "PUT", Bucket: "reports", Key: "incoming.bin", TTLSeconds: 300, }) download, err := nc.SignURL(ctx, lockwellnative.SignURLInput{ Method: "GET", Bucket: "reports", Key: "q1.txt", }) ``` `SignURL(ctx, in SignURLInput) (string, error)` returns an absolute URL whose authorization rides in a `token` query parameter. The URL can never exceed the minting key's scope: a read-only key minting a PUT URL is denied with 403 (`IsForbidden`). `TTLSeconds` is clamped server-side to `security.max_presign_ttl`; 0 uses the server default. See [signed URLs](/guide/signed-urls). ### Bucket notifications Native notifications configure signed webhook delivery. SNS/SQS/Lambda targets are a 501 non-goal. The per-config signing secret is returned exactly once for a new config ID; GET and same-ID updates carry only `HasSecret`. ```go views, err := nc.SetBucketNotification(ctx, "reports", lockwellnative.SetBucketNotificationInput{ Configs: []lockwellnative.NotificationConfig{{ ID: "reports-events", WebhookURL: "https://my-app.example.com/hooks/lockwell", Events: []string{"s3:ObjectCreated:*", "s3:ObjectRemoved:*"}, Filters: []lockwellnative.NotificationFilter{{Name: "prefix", Value: "incoming/"}}, }}, }) signingSecret := views[0].SigningSecret // shown once; store securely fmt.Println(views[0].HasSecret) // true; SigningSecret is empty on later GETs ``` | Method | Signature | | -------------------------- | ------------------------------------------------------------------------------------------------------ | | `SetBucketNotification` | `SetBucketNotification(ctx, bucket string, in SetBucketNotificationInput) ([]NotificationView, error)` | | `GetBucketNotification` | `GetBucketNotification(ctx, bucket string) ([]NotificationView, error)` | | `DeleteBucketNotification` | `DeleteBucketNotification(ctx, bucket string) error` | An empty `Configs` list on `SetBucketNotification` clears the configuration. See [webhooks](/guide/webhooks) for verifying deliveries. ### Errors Server errors are `*lockwellnative.NativeError` (decoded from `problem+json`) with `Code`, `Message`, `StatusCode`, and `RequestID`. The helpers map HTTP status and exact JSON codes to intent: | Helper | Status/code | | ------------------------------ | -------------------------------------------------- | | `IsUnauthorized(err)` | 401, including key revoked/expired/tenant disabled | | `IsForbidden(err)` | 403 scope or bucket-policy denial | | `IsNotFound(err)` | 404 missing bucket or key | | `IsAlreadyExists(err)` | `already_exists` | | `IsConflict(err)` | any 409 | | `IsIdempotencyConflict(err)` | `idempotency_conflict` | | `IsIdempotencyInProgress(err)` | `idempotency_in_progress` | | `IsPreconditionFailed(err)` | 412 conditional-write or copy-source precondition | | `IsRetentionBlocked(err)` | `retention_blocked` | | `IsLegalHoldBlocked(err)` | `legal_hold_blocked` | | `IsQuotaExceeded(err)` | 507 tenant storage quota exceeded | `AsNativeError(err) (*NativeError, bool)` extracts the concrete error without importing the type at the call site. ## `pkg/lockwelladmin` (the admin client) Talks to the JSON Admin API at `/admin/api/v1/` on the admin listener (never the public S3 port). It authenticates with an admin API bearer token minted with `lockwell admin-token create`. ```go admin, err := lockwelladmin.New( "https://admin.example.com", // admin listener, not the S3 port os.Getenv("LOCKWELL_ADMIN_TOKEN"), ) if err != nil { log.Fatal(err) } tenant, _, err := admin.CreateTenant(ctx, lockwelladmin.CreateTenantInput{ID: "acme", Name: "Acme Inc"}) // The secret is returned EXACTLY ONCE on create/rotate. Persist it now. nk, _, err := admin.CreateKey(ctx, "acme", lockwelladmin.CreateKeyInput{Scopes: "read,write,delete"}) fmt.Println(nk.AccessKeyID, nk.SecretKey) ``` `New` takes `WithHTTPClient` and `WithUserAgent`. ### Operations | Method | Signature | | --------------- | --------------------------------------------------------------------------------------------- | | `ListTenants` | `ListTenants(ctx) ([]Tenant, error)` | | `GetTenant` | `GetTenant(ctx, id string) (*Tenant, error)` | | `CreateTenant` | `CreateTenant(ctx, in CreateTenantInput) (*Tenant, *DryRunResult, error)` | | `DisableTenant` | `DisableTenant(ctx, id string, in DisableTenantInput) (*Plan, error)` | | `DeleteTenant` | `DeleteTenant(ctx, id string, in DeleteTenantInput) (*Plan, error)` | | `GetQuota` | `GetQuota(ctx, tenantID string) (*Quota, error)` | | `SetQuota` | `SetQuota(ctx, tenantID string, in SetQuotaInput) (*Quota, *DryRunResult, error)` | | `ClearQuota` | `ClearQuota(ctx, tenantID string, dryRun bool) (*Quota, *DryRunResult, error)` | | `GetUsage` | `GetUsage(ctx, tenantID string) (*Usage, error)` | | `ListAccounts` | `ListAccounts(ctx, tenantID string) ([]Account, error)` | | `CreateAccount` | `CreateAccount(ctx, tenantID string, in CreateAccountInput) (*Account, *DryRunResult, error)` | | `ListKeys` | `ListKeys(ctx, tenantID string) ([]Key, error)` (never returns secrets) | | `CreateKey` | `CreateKey(ctx, tenantID string, in CreateKeyInput) (*NewKey, *DryRunResult, error)` | | `RotateKey` | `RotateKey(ctx, tenantID, keyID string, in RotateKeyInput) (*NewKey, *DryRunResult, error)` | | `RevokeKey` | `RevokeKey(ctx, tenantID, keyID string, in RevokeKeyInput) (*Key, *DryRunResult, error)` | | `QueryAudit` | `QueryAudit(ctx, in QueryAuditInput) ([]AuditEvent, error)` | The secret on a created or rotated key is shown once on `NewKey.SecretKey` and is never recoverable. `ListKeys` returns metadata only. ::: warning A created or rotated key returns its secret exactly once. Persist it at that moment; there is no way to read it back. ::: ### Scope grammar `CreateKeyInput.Scopes` is a scope string. The simple form is a comma-separated verb list (`read`, `write`, `delete`, `admin`), for example `read,write,delete`. The resource form scopes the verbs to a bucket and optional prefix: ```text op=read:bucket=reports,op=write:bucket=reports,op=delete:bucket=reports op=read:bucket=reports:prefix=incoming/ ``` `ExpiresAt` is an optional RFC3339 or `YYYY-MM-DD` string (empty means never). ### Dry runs Every mutation accepts `DryRun: true`, which sends `?dryRun=true` so the server returns the plan and applies nothing. On a dry run the typed result is nil and the `*DryRunResult` is populated instead. ```go plan, err := admin.DeleteTenant(ctx, "acme", lockwelladmin.DeleteTenantInput{ Reason: "offboarding", Confirm: "acme", DryRun: true, }) fmt.Println(plan.Buckets, plan.Objects, plan.RetainedVersions) ``` Destructive lifecycle calls require a `Reason`, and `DeleteTenant` additionally requires `Confirm` to equal the tenant id. The server fails closed with a 412 when retention or a legal hold gates the delete. ### Errors Server errors are `*lockwelladmin.AdminError` with `Code`, `Message`, `StatusCode`, `RequestID`. Helpers: `IsNotFound` (404), `IsUnauthorized` (401), `IsForbidden` (403, RBAC or cross-tenant), `IsPreconditionFailed` (412, retention/legal-hold gated delete). `AsAdminError(err)` extracts the concrete error. See the [Admin API reference](/reference/admin-api). ## `pkg/lockwellkit` (the app kit) A thin composition over the admin and native clients. It introduces no new wire surface; every call goes through the admin and native JSON APIs. It composes them into the jobs a multi-tenant app would otherwise hand-roll. ```go admin, _ := lockwelladmin.New("https://admin.example.com", os.Getenv("LOCKWELL_ADMIN_TOKEN")) kit, _ := lockwellkit.New(admin, "https://objects.example.com") // 1) Provision: ensure the tenant exists (idempotent), mint a fresh scoped key, // optionally create a default bucket. Creds are returned ONCE. Store them. res, err := kit.ProvisionTenant(ctx, "acme", lockwellkit.ProvisionTenantInput{ DefaultBucket: "inbox", DefaultBucketCORS: &lockwellnative.CORSConfiguration{ Rules: []lockwellnative.CORSRule{{ AllowedOrigins: []string{"https://app.example.com"}, AllowedMethods: []string{"GET", "HEAD", "PUT"}, AllowedHeaders: []string{"content-type"}, }}, }, }) // persist res.Creds.AccessKeyID + res.Creds.SecretKey in your tenant store // 2) A per-tenant native client (cached per tenant+creds; auto-manages the token). client, _ := kit.ClientForTenant("acme", res.Creds) client.PutObject(ctx, lockwellnative.PutObjectInput{Bucket: "inbox", Key: "hi.txt", Body: strings.NewReader("hi")}) // 3) Browser direct upload/download. Hand the URL straight to the browser. up, _ := kit.SignedUploadURL(ctx, res.Creds, "inbox", "photo.jpg", lockwellkit.SignedUploadURLInput{TTLSeconds: 300, ContentType: "image/jpeg"}) dl, _ := kit.SignedDownloadURL(ctx, res.Creds, "inbox", "photo.jpg", 300) // Or update CORS later with another transient admin-scoped key. _, _ = kit.ConfigureBucketCORS(ctx, "acme", "inbox", lockwellnative.CORSConfiguration{ Rules: []lockwellnative.CORSRule{{ AllowedOrigins: []string{"https://app.example.com"}, AllowedMethods: []string{"GET", "HEAD", "PUT"}, }}, }) // 4) Verify an incoming webhook (constant-time HMAC-SHA256). ok := lockwellkit.VerifyWebhook(rawBody, req.Header.Get(lockwellkit.WebhookSignatureHeader), secret) ``` | Method | Signature | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `New` | `New(admin *lockwelladmin.Client, nativeEndpoint string, opts ...Option) (*Kit, error)` | | `ProvisionTenant` | `ProvisionTenant(ctx, tenantID string, in ProvisionTenantInput) (*ProvisionResult, error)` | | `ClientForTenant` | `ClientForTenant(tenantID string, creds TenantCreds) (*lockwellnative.Client, error)` | | `ConfigureBucketCORS` | `ConfigureBucketCORS(ctx, tenantID, bucket string, cfg lockwellnative.CORSConfiguration) (*lockwellnative.CORSConfiguration, error)` | | `SignedUploadURL` | `SignedUploadURL(ctx, creds TenantCreds, bucket, key string, in SignedUploadURLInput) (*SignedUpload, error)` | | `SignedDownloadURL` | `SignedDownloadURL(ctx, creds TenantCreds, bucket, key string, ttlSeconds int64) (string, error)` | | `VerifyWebhook` | `VerifyWebhook(rawBody []byte, signatureHeader string, secret []byte) bool` | | `Admin` | `Admin() *lockwelladmin.Client` | `ProvisionTenant` mints a data key only (default `read,write,delete`, or `op=read:bucket=,op=write:bucket=,op=delete:bucket=` when `Bucket` is set, or a custom `Scopes` string). It never mints a management-capable key. When `DefaultBucket` is set, the kit mints a transient admin-on-that-bucket key, creates the bucket, and revokes the transient key immediately, so the bucket-create capability never outlives the call. `ClientForTenant` caches one native client per (tenant, creds), so repeated calls share one token manager. Reach the underlying admin client via `kit.Admin()` for operations the kit does not wrap. > Store `SigningSecret` from the new-config response immediately. GET and same-ID updates expose only `HasSecret`; use > the stored value with `VerifyWebhook`. See [webhooks](/guide/webhooks). ## Coverage at a glance The full S3 operation matrix shared by all three S3 clients lives on the [S3 operations reference](/reference/s3-operations). The native and admin wire contracts are documented on the [native API](/reference/native-api) and [Admin API](/reference/admin-api) reference pages. --- --- url: /sdks/node.md description: >- The first-party Lockwell SDK for Node.js and Next.js, an encrypted alternative to the AWS S3 SDK with the shared LNW/1 core and an edge-safe compatibility entry. --- # 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`. ::: warning Historical package line The private `0.2.2` package line is historical and predates the selected PolyForm distribution payload. It is not an approved TangibleShift or commercial release; wait for B-010/B-013 clearance and the required written grant before commercial deployment. ::: 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 <= 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?)` (<= 1000) | | `copyObject` | `copyObject(dstBucket, dstKey, source)` | `putObject` options: `{ contentType?, metadata?, idempotencyKey?, ifMatch?, ifNoneMatch?, checksums?: { : }, contentLength? }`. `ifNoneMatch: '*'` creates only when absent; `ifMatch: ''` 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 }); 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, import from `@kelphect/sdk/edge`, 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. --- --- url: /sdks/java.md description: >- The first-party Lockwell SDK for the JVM, with a JDK 25 native binary LNW/1 data plane and explicit S3, HTTP/JSON, and JSON Admin compatibility surfaces. --- # Java SDK The first-party Lockwell SDK for the JVM is a JDK 25 client for Java 25 / Spring Boot 4 services and Lockwell's native binary LNW/1 data plane. LNW/1 uses TLS 1.3 and bounded binary frames directly over a socket; it does not wrap HTTP, JSON, XML, or S3. The same artifact also contains explicit S3 and HTTP/JSON compatibility clients plus the separate JSON Admin control plane. ::: warning Historical package line The `0.2.2` dependency below is a historical private artifact and predates the selected PolyForm distribution payload. It is not an approved TangibleShift or commercial release; wait for B-010/B-013 clearance and the required written grant before commercial deployment. ::: It shares the language-neutral SigV4 signing fixtures with the Go and Node SDKs, so all three sign byte-for-byte identically. ## Install ```xml com.lockwell lockwell-sdk 0.2.2 ``` The synchronous S3, native, and admin clients use fluent builders, are thread-safe, and never render secrets (`Credentials.toString()` redacts them). `LockwellAsyncClient` wraps an existing `LockwellClient` and optionally an application-owned `Executor`; it does not have a separate builder. The surfaces live in their own packages: | Package | Client | Surface | | ---------------------------- | --------------------------------------- | ------------------------ | | `com.lockwell.sdk` | `LockwellClient`, `LockwellAsyncClient` | S3 (SigV4 + XML) | | `com.lockwell.sdk.nativeapi` | `LockwellNativeClient` | native JSON (`/api/v1/`) | | `com.lockwell.sdk.springwire` | `LockwellNativeWireClient`, `LockwellNativeWireAsyncClient` | binary LNW/1 | | `com.lockwell.sdk.admin` | `LockwellAdminClient` | JSON admin API | | `com.lockwell.sdk.kit` | `LockwellKit` | the app kit | | `com.lockwell.sdk.spring` | auto-configuration | Spring Boot starter | ## Distribution and compatibility The Java SDK is published privately to GitHub Packages at `com.lockwell:lockwell-sdk`. Use a `read:packages` token in Maven/Gradle settings, pin an immutable version, and mirror the jar, POM, sources jar, generated checksums, release tag, and Lockwell supply-chain evidence into any offline/on-prem install media. Maven Central is deferred for the current TangibleShift adoption gate; use an internal/customer artifact mirror seeded from the vetted GitHub Packages artifact for customer builds. The SDK is compiled with `--release 25` and supports JDK 25. The separate `com.lockwell:lockwell-spring-boot-starter` contains only Spring Boot 4.1.1 autoconfiguration, health, and lifecycle glue for the same wire core; it does not contain a second implementation. Java 21 bytecode/runtime compatibility is not claimed. ### LNW/1 native wire ```java import com.lockwell.sdk.springwire.*; var properties = new LockwellNativeWireProperties(); properties.setHost("lockwell.internal.example"); properties.setAccessKeyId(System.getenv("LOCKWELL_ACCESS_KEY_ID")); properties.setSecretKey(System.getenv("LOCKWELL_SECRET_KEY").toCharArray()); try (var client = new LockwellNativeWireClient(properties)) { var page = client.listBuckets(NativeWireTypes.RequestOptions.defaults()); } ``` The wire client exposes typed synchronous and virtual-thread asynchronous APIs for every registry operation: buckets, streaming objects and ranges, checksums/metadata, pagination, copy, multipart resume/abort, versions/delete markers, Object Lock retention/legal hold, tags, CORS, notifications, signed capabilities, readiness, and capability negotiation. Unknown critical fields and frames fail closed; application retries are never hidden, while narrowly scoped authentication retries expose bounded metadata and use fresh handshakes. The public wire model is grouped under `NativeWireTypes`: `BucketResult`, `BucketPage`, `PutObjectRequest`, `GetObjectRequest`, `GetObjectResult`, `HeadObjectResult`, `ByteRange`, `ChecksumSet`, `ListObjectsRequest`, `ObjectPage`, `ObjectSummary`, `CopyObjectRequest`, `ListVersionsRequest`, `VersionPage`, `DeleteObjectIdentifier`, `BatchDeleteRequest`, `BatchDeleteSuccess`, `BatchDeleteFailure`, `MultipartParts`, `CorsRule`, `CorsConfiguration`, `NotificationConfig`, `SignedCapabilityRequest`, `SignedCapability`, `TagsResult`, and `LegalHoldResult`. These records carry the same bounded operation fields as the language-neutral registry rather than untyped maps or JSON documents. Streaming responses use the typed `ObjectStreamHandler` callback so callers can process payload bytes without whole-object buffering. Pin the SDK and Lockwell server/image to the same release line unless release notes explicitly allow otherwise. The SDK targets the versioned JSON Admin API (`/admin/api/v1`) and native API (`/api/v1`); installers can compare `/admin/api/v1/openapi.json` and `/api/v1/openapi.json` against the pinned SDK before provisioning tenants. ## `LockwellClient` (the S3 client) ```java import com.lockwell.sdk.*; import java.time.Duration; import java.util.Map; LockwellClient client = LockwellClient.builder() .endpoint("https://objects.example.com") .credentials(new Credentials(System.getenv("LOCKWELL_ACCESS_KEY_ID"), System.getenv("LOCKWELL_SECRET_KEY"))) .requestTimeout(Duration.ofSeconds(30)) .retryPolicy(RetryPolicy.defaults()) // opt in; honors Retry-After on 429/5xx .build(); // SSE-S3, a server-verified CRC64NVME checksum, and idempotent retry, in one call. var put = client.putObject("reports", "q1.txt", "hello".getBytes(), new LockwellClient.PutOptions() .contentType("text/plain") .serverSideEncryption() .checksum("CRC64NVME") .idempotencyKey("q1-2026")); var got = client.getObject("reports", "q1.txt", Map.of()); System.out.println(new String(got.body())); // Stream a large file (checksum sent in an aws-chunked trailer; no buffering). try (var in = java.nio.file.Files.newInputStream(java.nio.file.Path.of("big.bin"))) { client.putObjectStream("reports", "big.bin", in, "CRC64NVME", null); } // Presigned GET; PUT, HEAD, and DELETE helpers are also available. String 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. On an endpoint with wildcard bucket DNS, add `.virtualHostedStyle(true)` to the builder; normal requests and presigned URLs then put the bucket, including dotted bucket names, in the signed host. ### Builder `LockwellClient.builder()` accepts `.endpoint(...)`, `.credentials(...)`, `.httpClient(HttpClient)`, `.userAgent(String)`, `.clock(Supplier)`, `.requestTimeout(Duration)`, `.retryPolicy(RetryPolicy)`, `.region(String)`, `.virtualHostedStyle(boolean)`, and `.responseMetadataListener(Consumer)`. S3 retries are off by default for backward compatibility; pass `RetryPolicy.defaults()` to retry safe and idempotent requests with backoff, jitter, and `Retry-After` handling. `requestTimeout` is per HTTP attempt. If retries are enabled, total wall time can include more than one request timeout plus retry backoff. Streaming uploads are bounded for the full upload attempt, so size this timeout for large object writes. ### Buckets | Method | Signature | | --------------------- | ------------------------------------------------------------------------------------------------- | | `createBucket` | `createBucket(String bucket)` / `createBucket(bucket, String objectLockMode, int objectLockDays)` | | `headBucket` | `headBucket(String bucket)` | | `deleteBucket` | `deleteBucket(String bucket)` | | `putBucketVersioning` | `putBucketVersioning(bucket, String status)` (`"Enabled"`/`"Suspended"`) | | `getBucketVersioning` | `String getBucketVersioning(bucket)` | ### Objects | Method | Signature | | ----------------- | ------------------------------------------------------------------------------------------------------- | | `putObject` | `PutResult putObject(bucket, key, byte[] body, PutOptions opts)` | | `putObjectStream` | `PutResult putObjectStream(bucket, key, InputStream source, String checksumAlgorithm, PutOptions opts)` | | `getObject` | `GetResult getObject(bucket, key, Map queryAndRange)` | | `getObjectStream` | `StreamingGetResult getObjectStream(bucket, key, Map queryAndRange)` | | `headObject` | `GetResult headObject(bucket, key)` | | `deleteObject` | `deleteObject(bucket, key)` | | `deleteObjects` | `DeleteObjectsResult deleteObjects(bucket, List objects, boolean quiet)` | | `copyObject` | `CopyResult copyObject(srcBucket, srcKey, srcVersionId, dstBucket, dstKey, ...)` | `PutOptions` is a fluent builder: `.contentType(v)`, `.metadata(k, v)`, `.idempotencyKey(v)`, `.serverSideEncryption()`, `.checksum(alg)`, `.objectLock(mode, retainUntilRfc3339)`, `.legalHold(boolean)`. The `queryAndRange` map on `getObject`/`getObjectStream` carries `range`, `partNumber`, `versionId`, and the `response-*` overrides; pass `Map.of()` for a plain read. `StreamingGetResult` is `AutoCloseable` and exposes the `InputStream body()` plus a `readAllBytes()` convenience; close it (try-with-resources) to release the connection. ### Listing and paginators | Method | Signature | | ---------------------- | -------------------------------------------------------------------------------------------- | | `listObjectsV2` | `ListResult listObjectsV2(bucket, String prefix, Integer maxKeys, String continuationToken)` | | `listObjects` | `ListV1Result listObjects(bucket, prefix, marker, Integer maxKeys, delimiter)` | | `listObjectVersions` | `ListVersionsResult listObjectVersions(bucket, ListVersionsOptions opts)` | | `listMultipartUploads` | `ListMultipartUploadsResult listMultipartUploads(bucket, ListUploadsOptions opts)` | | `listParts` | `ListPartsResult listParts(bucket, key, uploadId, ListPartsOptions opts)` | The marker-paged lists each have a `Paginator

` that threads markers for you. A paginator is an `Iterable

` of pages: drive it with `hasMorePages()` / `nextPage()`, a for-each, or `toList()`. ```java var p = client.listObjectVersionsPaginator("reports", new LockwellClient.ListVersionsOptions().prefix("logs/")); while (p.hasMorePages()) { var page = p.nextPage(); page.versions().forEach(v -> System.out.println(v.key() + " " + v.versionId())); } ``` The constructors are `listObjectVersionsPaginator`, `listMultipartUploadsPaginator`, and `listPartsPaginator`. The `ListVersionsOptions` / `ListUploadsOptions` / `ListPartsOptions` builders carry `prefix`, `delimiter`, the relevant markers, and the page cap. ### Multipart `createMultipartUpload(bucket, key, contentType)` returns a `CreateMpuResult`; the checksum-aware overload `createMultipartUpload(bucket, key, contentType, checksumAlgorithm)` returns a `CreateMpuChecksumResult`. Then `uploadPart(...)`, `uploadPartCopy(...)`, `completeMultipartUpload(bucket, key, uploadId, parts)`, and `abortMultipartUpload(bucket, key, uploadId)`. The checksum-aware `uploadPart` overload sends a verified per-part digest and folds it into the composite checksum on complete. ### Tagging, Object Lock, presign `putObjectTagging(bucket, key, Map tags)`, `getObjectTagging(bucket, key)`, `deleteObjectTagging(bucket, key)`, typed `getTypedObjectRetention` / `putObjectRetention`, typed `getObjectLegalHoldStatus` / `putObjectLegalHold`, and the legacy `getObjectRetention` / `getObjectLegalHold` readers. Each Object Lock mutation accepts a version id overload. The four presign methods are `presignGetObject`, `presignPutObject`, `presignHeadObject`, and `presignDeleteObject`. Retention and legal hold can be set on the write through `PutOptions.objectLock(...)` and `.legalHold(...)` or changed afterward with the typed methods, subject to Object Lock enforcement. The native client separately exposes `signUrl` for constrained native GET/PUT URLs. ### Errors and the async client Server errors throw `ApiException` with `code()`, `statusCode()`, `requestId()`, and `isNotFound()`. A `LockwellAsyncClient` wraps an existing synchronous client with `CompletableFuture` results for bucket, object, listing/paginator, copy, multipart/checksum, tagging, typed Object Lock, and all four presign operations. Closing the wrapper only closes an executor it created; a supplied executor remains application-owned. Cancelling a future marks that future cancelled, while interruption of an in-flight HTTP exchange depends on the supplied executor and call state. `NativeException` and `AdminException` preserve RFC 9457 `type()`, `title()`, `detail()`, `instance()`, and `extensions()` alongside the stable code/status/request id. Branch on the exact machine code or documented predicate, not the human detail string. ## `LockwellNativeClient` (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 management is thread-safe (single-flight refresh under a lock), so concurrent callers share one in-flight mint. The Java native client retries safe/idempotent requests on transient transport errors, `429`, and `5xx` by default, honoring `Retry-After`. `GET`, `HEAD`, and `DELETE` are replayed automatically; `PUT` and `POST` require an `Idempotency-Key`, such as `PutOptions.idempotencyKey(...)`. Pass `retryPolicy(RetryPolicy.disabled())` to attempt each request once. ```java import com.lockwell.sdk.RetryPolicy; import com.lockwell.sdk.nativeapi.*; import com.lockwell.sdk.nativeapi.NativeTypes.*; import java.time.Duration; LockwellNativeClient client = LockwellNativeClient.builder() .endpoint("https://objects.example.com") // public S3 port; native API at /api/v1 .accessKeyId(System.getenv("LOCKWELL_ACCESS_KEY_ID")) .secretKey(System.getenv("LOCKWELL_SECRET_KEY")) .requestTimeout(Duration.ofSeconds(30)) .retryPolicy(RetryPolicy.defaults()) .build(); client.createBucket("reports"); // An idempotent PUT needs a body-integrity signal: pass an expected checksum. PutResult put = client.putObject("reports", "q1.txt", "hello".getBytes(), new PutOptions().contentType("text/plain").idempotencyKey("q1-2026").checksum("sha256", sha)); // Streaming GET (InputStream body, no whole-object buffering). Caller closes it. try (GetResult got = client.getObject("reports", "q1.txt")) { got.body().transferTo(System.out); } // Streaming PUT from an InputStream supplier. client.putObject("reports", "big.bin", () -> Files.newInputStream(path), new PutOptions().contentType("application/octet-stream")); ``` ### Buckets and objects | Method | Signature | | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | | `listBuckets` | `List listBuckets()` | | `createBucket` | `createBucket(bucket)` / `createBucket(bucket, CreateBucketOptions opts)` | | `getBucket` / `deleteBucket` | `Bucket getBucket(bucket)` / `void deleteBucket(bucket)` | | `getBucketVersioning` / `setBucketVersioning` | `VersioningState` (`setBucketVersioning(bucket, status)`) | | `setBucketCORS` / `getBucketCORS` / `deleteBucketCORS` | browser CORS rules | | `putObject` | `putObject(bucket, key, byte[] body[, PutOptions])` or `putObject(bucket, key, Supplier body, PutOptions)` (streaming) | | `getObject` | `getObject(bucket, key)` / `getObject(bucket, key, String range, String versionId)` | | `headObject` | `headObject(bucket, key)` / `headObject(bucket, key, String versionId)` | | `deleteObject` | `deleteObject(bucket, key)` / `deleteObject(bucket, key, String versionId)` | | `listObjects` | `ListObjectsResult listObjects(bucket, ListObjectsOptions opts)` | | `batchDeleteObjects` | `BatchDeleteResult batchDeleteObjects(bucket, List objects)` | | `copyObject` | `CopyResult copyObject(destBucket, destKey, sourceBucket, sourceKey, CopyOptions opts)` | `PutOptions` here is a native fluent builder: `.contentType(v)`, `.idempotencyKey(v)`, `.ifMatch(v)` / `.ifAbsent()` for conditional writes, `.checksum(alg, value)`, and metadata. The streaming overload takes a `Supplier` so the body is opened lazily. S3 streaming uploads/downloads and multipart parts have additive `ProgressListener` overloads. `TransferProgress` reports cumulative bytes, known/unknown total, direction, and part number; listener execution applies backpressure and listener failure cancels/closes the transfer. Configure `responseMetadataListener(Consumer)` on the S3, native, or admin builder to observe successful request-id/trace headers without changing result records. `listObjectsV2` is the explicit page API; `paginateObjectsV2` and `iterateObjectsV2` provide lazy continuation-token iteration. For production retry and timeout settings, see the [Java native client guide](/sdks/java-native). ### ERP security review For fiscal and GDPR artifacts, the Java native client and `StorageProfiles` write through the same server-side object pipeline as every Lockwell data-plane write. Production configs set `encryption.enabled = true`, so native writes are stored as encrypted chunks under per-tenant data keys. Verify finalized objects with `HeadResult.encrypted()` or `GetResult.encrypted()`, both derived from the `X-Lockwell-Encrypted` response header, and do not run tenant-handling ERP data on an encryption-disabled deployment. Key management is deliberately explicit: `encryption.key_provider = "local"` is the only accepted runtime provider today. External KMS and customer-managed key support is deferred, so unsupported provider values fail config validation instead of creating fake compliance evidence or hidden storage network calls. `lockwell keys rotate` affects future per-tenant object writes; `lockwell keys rewrap ...` is the tracked workflow for historical chunks. Access-key master-key rewrap is a separate credential-maintenance workflow. Residency is decided by deployment placement, not by the SDK. SaaS deployments keep TangibleShift artifacts in EU/Portugal-approved infrastructure by placing the Lockwell node, `storage.data_dir`, metadata store, backups, and key escrow there and exposing only that endpoint to the ERP. On-prem/server and desktop-local deployments keep objects local by running `lockwelld` against a customer-controlled `storage.data_dir` and local backup/key custody. Keep bucket and key names non-sensitive. Object bytes are encrypted, but bucket names, object keys, object tags, and audit resource strings remain operational metadata. A `metadata-backup` stream is decrypted by design except for application-sealed fields such as access-key secrets, so treat it as a secret. Complete fiscal backups must include the master key, `metadata-backup` stream, data-encryption key directory, and blob store; backup/restore evidence must prove retention policies, legal holds, checksums, and audit trail survive restore. ### Tags, retention, legal hold, versions, multipart `getObjectTags` / `setObjectTags`, `getObjectRetention` / `setObjectRetention(bucket, key, mode, retainUntil)`, `getObjectLegalHold` / `setObjectLegalHold(bucket, key, status)`, `listObjectVersions(bucket, ListVersionsOptions)`, and the multipart set (`createMultipartUpload`, `uploadPart`, `listParts`, `completeMultipartUpload`, `abortMultipartUpload`, `listMultipartUploads`). ### Bucket CORS Browser CORS is available on the native client as `CORSConfiguration` / `CORSRule`: ```java import com.lockwell.sdk.nativeapi.NativeTypes.CORSConfiguration; import com.lockwell.sdk.nativeapi.NativeTypes.CORSRule; import java.util.List; CORSConfiguration cfg = new CORSConfiguration(List.of(new CORSRule( "browser-direct", List.of("https://app.example.com"), List.of("GET", "HEAD", "PUT"), List.of("content-type"), List.of("ETag"), 600))); CORSConfiguration stored = client.setBucketCORS("reports", cfg); CORSConfiguration got = client.getBucketCORS("reports"); client.deleteBucketCORS("reports"); ``` Changing CORS is an admin-scoped bucket operation. For app onboarding, prefer `kit.configureBucketCORS(...)` or `new ProvisionOptions().bucketCORS(...)`, which use a transient admin key and revoke it after the update. ### Signed URLs (GET and PUT) The native API supports signed write URLs (unlike the S3 presigner). `signUrl` returns a `String` usable without a bearer token: ```java String download = client.signUrl("GET", "reports", "q1.txt", 900); String upload = client.signUrl("PUT", "reports", "incoming.bin", 600); ``` ### Signed-URL constraints (browser-direct upload/download) Pass `SignedUrlOptions` to pin properties the browser cannot be trusted to send correctly. The constraints are HMAC-covered in the signed token and enforced by the server at dispatch time. See [signed URLs](/guide/signed-urls) for the full set and the security shape. ```java import com.lockwell.sdk.nativeapi.NativeTypes.SignedUrlOptions; // PUT: pin content-type, cap size, verify the body, and make a retry idempotent. String upload = client.signUrl("PUT", "fiscal", "2026/0001.pdf", 300, new SignedUrlOptions() .contentType("application/pdf") .contentLengthMax(10L * 1024 * 1024) .checksum("SHA256", sha256B64) .idempotencyKey("invoice-2026-0001")); // PUT prefix-scoped: browser chooses the suffix under "imports/". String prefixUpload = client.signUrl("PUT", "imports", null, 300, new SignedUrlOptions().keyPrefix("imports/")); // GET: predictable download headers. String download = client.signUrl("GET", "fiscal", "2026/0001.pdf", 300, new SignedUrlOptions() .responseContentType("application/pdf") .responseContentDisposition("attachment; filename=\"invoice-2026-0001.pdf\"")); ``` For `keyPrefix`, insert the full object key under the prefix into the returned URL's path before the `?token=` query. See [signed URLs](/guide/signed-urls#prefix-scoped-uploads). `signUrlResult(...)` accepts the same options and returns the full `SignedUrl` record. See [signed URLs](/guide/signed-urls). ### Bucket notifications Webhook-only delivery; a new config ID returns its signing secret exactly once. GET and same-ID updates report only `hasSecret()`. ```java import java.util.List; NotificationConfig cfg = new NotificationConfig("https://app.example.com/hook", List.of("s3:ObjectCreated:*", "s3:ObjectRemoved:*")) .id("reports-events") .filter("prefix", "incoming/"); NotificationConfiguration created = client.setBucketNotification("reports", cfg); String signingSecret = created.configs().get(0).signingSecret(); // store securely NotificationConfiguration current = client.getBucketNotification("reports"); boolean signed = current.configs().get(0).hasSecret(); // signingSecret() is null on GET client.deleteBucketNotification("reports"); // clear ``` `setBucketNotification` also takes a `List` overload for multiple targets. ### Errors Native errors throw `NativeException` with `code()`, `statusCode()`, `requestId()` and the predicates `isUnauthorized()` (401), `isForbidden()` (403), `isNotFound()` (404), `isConflict()` (409), `isPreconditionFailed()` (412), `isQuotaExceeded()` (507). ### ERP error taxonomy and retry classification Use `ErpErrors.classify(Throwable)` when mapping Lockwell failures to TangibleShift RFC 9457 problem details. It accepts `AdminException` and `NativeException` and returns a stable `Classification` with `category`, `statusCode`, `code`, `requestId`, `problemType`, `auditReason`, `retryDecision`, and write-retry proof flags. It intentionally does not copy the raw exception message, so secrets, signed URLs, object keys, tenant names, and customer names do not leak into ERP problem bodies. The classifier keys on exact Lockwell JSON problem codes first. ERP-specific codes include `tenant_disabled`, `key_revoked`, `key_expired`, `quota_exceeded`, `retention_blocked`, `legal_hold_blocked`, `idempotency_conflict`, and `idempotency_in_progress`; status-only fallback is used only when a non-Lockwell response has no machine code. ```java import com.lockwell.sdk.kit.ErpErrors; import com.lockwell.sdk.kit.ErpErrors.Category; try { nativeClient.putObject("imports", key, body, opts); } catch (RuntimeException e) { var c = ErpErrors.classify(e); if (c.category() == Category.RATE_LIMITED || c.category() == Category.TRANSIENT_UPSTREAM) { // Retry writes only when the request has an idempotency key and checksum. if (c.writeRetryRequiresIdempotencyKey()) scheduleReplayWithProof(c.requestId()); } throw toProblemDetail(c.problemType(), c.statusCode(), c.code(), c.requestId()); } ``` * `NOT_FOUND`, `ALREADY_EXISTS`, `VALIDATION_ERROR`, `FORBIDDEN`: do not retry blindly; fix ERP state, scope, or request shape. * `UNAUTHORIZED`, `KEY_EXPIRED`: refresh the admin token or native bearer/credential path, then retry once. * `KEY_REVOKED`, `TENANT_DISABLED`: operator action required; rotate/select a new purpose key or stop tenant work. * `QUOTA_EXCEEDED`: operator action required; raise quota or stop the import/export job. * `RATE_LIMITED`, `TRANSIENT_UPSTREAM`, `IDEMPOTENCY_IN_PROGRESS`: back off; retry writes only with an idempotency key and body-binding checksum. * `RETENTION_BLOCKED`, `LEGAL_HOLD_BLOCKED`: do not retry; surface fiscal/legal evidence and keep the object. * `PRECONDITION_FAILED`, `IDEMPOTENCY_CONFLICT`: do not retry with changed bytes; reconcile the ERP row and object version. For multi-GB SAF-T/import/export artifacts, prefer streaming APIs, set `contentLength` when known so quota is checked up front, and choose multipart for resumable large writes. Any automated replay of a write must carry the same idempotency key plus a checksum over the same bytes. ## `LockwellAdminClient` (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`). ```java import com.lockwell.sdk.admin.*; import com.lockwell.sdk.admin.AdminTypes.*; LockwellAdminClient admin = LockwellAdminClient.builder() .endpoint("https://admin.example.com") // admin listener, NOT the S3 port .token(System.getenv("LOCKWELL_ADMIN_TOKEN")) // Authorization: Bearer .build(); for (Tenant t : admin.listTenants()) System.out.println(t.id()); Tenant acme = admin.createTenant("acme", "Acme Inc"); // Every mutation has a *DryRun twin that sends ?dryRun=true and returns the plan. DryRunPlan plan = admin.deleteTenantDryRun("acme", "offboarding", "acme"); // The secret is returned exactly once on create/rotate. Store it immediately. NewKey key = admin.createKey("acme", new CreateKeyOptions("sa-1", "read,write,delete", null, "ERP bootstrap", "ts-install-123:reports")); System.out.println(key.secretKey()); ``` | Method | Dry-run twin | | --------------------------------------------------------- | ----------------------------------------- | | `listTenants()` / `getTenant(id)` | (read-only) | | `createTenant(id, name)` | `createTenantDryRun(id, name)` | | `disableTenant(id, reason)` | `disableTenantDryRun(id, reason)` | | `deleteTenant(id, reason, confirm)` | `deleteTenantDryRun(id, reason, confirm)` | | `getQuota(id)` / `setQuota(id, bytes)` / `clearQuota(id)` | `setQuotaDryRun`, `clearQuotaDryRun` | | `getUsage(id)` | (read-only) | | `listAccounts(id)` / `createAccount(id, name)` | `createAccountDryRun(id, name)` | | `listKeys(id)` (never returns secrets) | (read-only) | | `createKey(id, CreateKeyOptions)` | `createKeyDryRun(id, opts)` | | `rotateKey(id, keyId, RotateKeyOptions)` | `rotateKeyDryRun(id, keyId, opts)` | | `revokeKey(id, keyId, reason)` | `revokeKeyDryRun(id, keyId, reason)` | | `queryAudit(AuditQuery q)` | (read-only) | `CreateKeyOptions(accountId, scopes, expiresAt[, reason[, externalRef]])` follows the scope grammar (verb list `read,write,delete,admin`, or resource form `op=read:bucket=reports:prefix=in/,op=write:bucket=reports:prefix=in/`). `accountId` is a real service-account id; use `externalRef` for caller-owned ERP installation/purpose metadata. The secret on a created or rotated key is on `NewKey.secretKey()` and is shown exactly once. Errors throw `AdminException` with `isUnauthorized()`, `isForbidden()`, `isNotFound()`, `isRetentionBlocked()` (the 412 retention/legal-hold gate). See the [Admin API reference](/reference/admin-api). ::: warning `NewKey.secretKey()` is readable exactly once, on create or rotate. Persist it immediately; it is never recoverable afterward. ::: ## `LockwellKit` (the app kit) Composes the admin and native clients with near-zero glue. The per-tenant native-client cache is a `ConcurrentHashMap`, so it is thread-safe. ```java import com.lockwell.sdk.kit.*; import com.lockwell.sdk.kit.KitTypes.*; import com.lockwell.sdk.nativeapi.LockwellNativeClient; import java.time.Duration; LockwellKit kit = LockwellKit.builder() .adminEndpoint("https://admin.example.com") // admin listener .adminToken(System.getenv("LOCKWELL_ADMIN_TOKEN")) .nativeEndpoint("https://objects.example.com") // public S3 port; native API at /api/v1 .build(); // Provision: ensure the tenant exists, mint a fresh read/write/delete key // (optionally bucket-scoped), optionally create a default bucket. Secret returned ONCE. ProvisionResult p = kit.provisionTenant("acme", new LockwellKit.ProvisionOptions().defaultBucket("uploads") .bucketCORS(new CORSConfiguration(List.of(new CORSRule( List.of("https://app.example.com"), List.of("GET", "HEAD", "PUT")))))); store(p.credentials()); // {accessKeyId, secretKey} // ERP-safe bootstrap retry: keyExternalRef is the stable external reference. EnsureProvisionResult ensured = kit.ensureTenantProvisioning("acme", new LockwellKit.ProvisionOptions() .keyExternalRef("ts-install-123:uploads") .bucketScope("uploads") .reason("ERP bootstrap")); if (ensured.key().created()) { store(ensured.key().credentials()); // secret shown ONCE } else { rememberAccessKeyId(ensured.key().key().accessKeyId()); } // A per-tenant native client (cached per (tenant, creds); auto-manages the bearer token). LockwellNativeClient acme = kit.clientForTenant("acme", p.credentials()); acme.putObject("uploads", "hello.txt", "hi".getBytes()); // Browser direct upload/download. Signed URLs the browser uses with NO bearer token. BrowserSignedUrl up = kit.signedUploadUrl(p.credentials(), "uploads", "in.bin", Duration.ofMinutes(10), "application/octet-stream"); BrowserSignedUrl dl = kit.signedDownloadUrl(p.credentials(), "uploads", "hello.txt", Duration.ofMinutes(15)); // Or update CORS later with another transient admin-scoped key. kit.configureBucketCORS("acme", "uploads", new CORSConfiguration(List.of( new CORSRule(List.of("https://app.example.com"), List.of("GET", "HEAD", "PUT"))))); // Verify an incoming webhook (constant-time HMAC-SHA256). boolean ok = LockwellKit.verifyWebhook(requestBodyBytes, request.getHeader("X-Lockwell-Signature"), mySecret); ``` `ProvisionOptions` is a fluent builder: `.tenantName(name)`, `.bucketScope(bucket)`, `.defaultBucket(bucket)`, `.bucketCORS(cfg)`, `.keyExternalRef(externalRef)`, `.keyExpiresAt(rfc3339)`, `.reason(reason)`. The default bucket and optional CORS rules are applied with a transient admin-on-that-bucket key that is revoked immediately, so the long-lived tenant key stays admin-free. Use `provisionTenant` when the app intentionally wants a fresh one-time secret every call. Use `ensureTenantProvisioning` for idempotent ERP/on-prem bootstrap: it requires `.keyExternalRef(...)`, lists active key metadata, and reuses a matching `externalRef + scopes + expiry` key without creating another live key. Existing key metadata never carries the secret. If the ERP lost the one-time secret before storing it, rotate the returned key through `kit.admin().rotateKey(...)` with a `RotateKeyOptions` audit reason and persist the new secret from that rotation response; omitted rotate scopes/expiry preserve the existing key's values. `.reason(...)` on `ProvisionOptions`, `CreateKeyOptions`, and `RotateKeyOptions` is written into Lockwell's audit row; the Admin API also emits `X-Request-Id` and stores it as audit correlation. Concurrent first bootstrap attempts can still both observe "no key yet" before either create reaches the server; serialize that first call with the ERP's DB lock/outbox until Lockwell exposes a server-side idempotency-key primitive for key create. ### ERP transaction, outbox, and reconciliation Lockwell calls are not part of the ERP database transaction. During the ERP tenant/company transaction, only derive the opaque refs (`tenantPublicRef`, `installationRef`, `lockwellTenantId`, company ref, purpose) and persist an ERP outbox record. Do not call `ensureTenantProvisioning`, create buckets, or mint keys until the ERP row has committed. The outbox worker is the only place that talks to Lockwell for onboarding. It should run these steps with one ERP database lock per tenant/company: call `ensureTenantProvisioning` with a stable `keyExternalRef`, ensure the default bucket/profile, create purpose keys with `ErpScopes`, store each access-key id, and store one-time secrets only on the nested key result's `created=true`/credentials-present path. Retries are safe when every key uses stable `externalRef + scopes + expiry`: `ensureTenant` accepts an existing tenant, `ensureBucket` accepts an existing bucket, and `ensureKey` reuses matching active keys instead of duplicating live credentials. If the ERP lost a one-time secret, rotate the returned key with an audited reason and store the replacement secret. Reconciliation is ERP-owned. Keep mapping rows in states such as `pending_lockwell`, `active`, `failed_lockwell`, `disabled`, and `delete_ready`; compare them with `listTenants`, `getTenant`, and `listKeys` output. A committed ERP row with missing Lockwell resources goes back through the outbox. A Lockwell tenant with no active ERP mapping is an orphan: first disable it with an audited reason, revoke purpose keys, and only then decide whether it can be deleted. Use `deleteTenantDryRun` to preview affected buckets, keys, versions, retention, legal holds, and bytes before destructive cleanup. Offboarding is a sequence, not a helper. Disabling the ERP tenant, disabling the Lockwell tenant, revoking purpose keys, retaining fiscal artifacts, deleting non-retained data, and final tenant deletion are separate audited steps. Never delete a Lockwell tenant just because the ERP row was removed; fiscal retention and legal holds can outlive the ERP account. Topology is explicit. In SaaS, TangibleShift operates the regional Lockwell deployment and the ERP backend owns the admin token; browsers and customer code never see it. In customer on-prem server installs, Lockwell runs beside the ERP server as an external daemon owned by the customer/operator, with a scoped provisioning token stored in the ERP secret store. In desktop-local installs, the installer may bootstrap a local `lockwelld`, but retention-grade evidence requires documented backups, key custody, and clock monitoring before fiscal data is stored there. Retention deadlines are enforced by the Lockwell server clock at write/delete time. The ERP may compute the policy date, but on-prem and desktop deployments must monitor NTP/time drift and record the clock source used for fiscal evidence. If clock drift is detected, pause retention-sensitive onboarding, offboarding, and delete workflows until the operator has reconciled the time source. ### ERP tenant/company/purpose layout `ErpScopes` keeps the ERP-side mapping table explicit. TangibleShift stores opaque `tenantPublicRef`, `installationRef`, and `lockwellTenantId` values, then the helper derives purpose paths and key metadata. Bucket names and prefixes must not contain customer legal names, tax ids, or free-form site labels. ```java import com.lockwell.sdk.kit.ErpScopes; import com.lockwell.sdk.kit.ErpScopes.Purpose; var mapping = ErpScopes.tenantMapping("ts_tenant_7f3a", "install_9b12", "lwtenant_9"); var imports = ErpScopes.purposePath(mapping, "co_a812", Purpose.IMPORTS); System.out.println(imports.bucket()); // imports System.out.println(imports.prefix()); // companies/co_a812/imports/ System.out.println(imports.externalRef()); // ts:install_9b12:ts_tenant_7f3a:co_a812:imports kit.ensureKey(mapping.lockwellTenantId(), ErpScopes.temporaryBrowserUploadKey(imports, "2026-01-02T03:04:05Z", "ERP import upload")); ``` All company object keys should start with `companies///`. The purpose-scoped access-key templates cover `fiscalArchiveAppendKey`, `importReadWriteKey`, `temporaryBrowserUploadKey`, `exportReadKey`, `dataRightsReadWriteKey`, and `supportDiagnosticReadKey`. Store the access key id per company/purpose so ERP support can rotate/revoke by purpose, restating the same generated scope and audit reason. After rotation, persist the new secret and drop the ERP credential cache; `clientForTenant` sees new credentials as a fresh cache key, so cache invalidation creates a new native token manager. Tests should assert `path.prefix()` on every write/read path to catch cross-company mistakes. `configureBucketCORS` updates an existing bucket the same way: mint a transient admin-scoped key, call the native CORS route, revoke the key. `signedUploadUrl` and `signedDownloadUrl` accept either `TenantCredentials` or a `LockwellNativeClient`. For browser-direct flows with constraints (pinned content-type, size cap, checksum, idempotency key, prefix scope, or GET response overrides), pass a `SignedUrlOptions`: ```java import com.lockwell.sdk.nativeapi.NativeTypes.SignedUrlOptions; BrowserSignedUrl up = kit.signedUploadUrl(creds, "inbox", "photo.jpg", Duration.ofMinutes(5), new SignedUrlOptions().contentType("image/jpeg").contentLengthMax(5L * 1024 * 1024)); ``` ### ERP storage profiles The Java kit includes `StorageProfiles` for ERP-owned artifact classes. These helpers encode safe Lockwell option combinations; they do **not** decide TangibleShift's fiscal retention durations, GDPR outcomes, or support retention policy. ```java import com.lockwell.sdk.kit.StorageProfiles; client.createBucket("fiscal-archive", StorageProfiles.fiscalArchiveBucket()); var fiscal = StorageProfiles.fiscalArchiveWrite( "application/pdf", "2033-01-01T00:00:00Z", // ERP-owned retain-until date "SHA256", pdfSha256Base64, "invoice-2026-0001"); client.putObject("fiscal-archive", "2026/0001.pdf", pdfBytes, fiscal.putOptions()); client.setObjectRetention("fiscal-archive", "2026/0001.pdf", fiscal.retention().mode(), fiscal.retention().retainUntil()); // COMPLIANCE var importUpload = StorageProfiles.importUploadUrl(Duration.ofMinutes(5), "application/json", 2L * 1024 * 1024, "SHA256", importSha256Base64, "import-job-123", "imports/"); BrowserSignedUrl up = kit.signedUploadUrl(creds, "imports", null, importUpload.ttl(), importUpload.options()); var exportDownload = StorageProfiles.exportDownloadUrl(Duration.ofMinutes(5), "application/pdf", "attachment; filename=\"invoice-2026-0001.pdf\"", "ERP export invoice-2026-0001"); BrowserSignedUrl dl = kit.signedDownloadUrl(creds, "exports", "2026/0001.pdf", exportDownload.ttl(), exportDownload.options()); ``` Apply retention (and any legal hold) before acknowledging a fiscal archive as finalized. The native API applies retention after the object exists, so ERP startup/reconciliation should use the ERP archive ledger to find finalized object keys whose retention was not applied after a crash and repair them before deletion is possible. Named profiles: * `fiscal-archive`: versioning + Object Lock bucket, no-overwrite write options, checksum verification, idempotency, and explicit `COMPLIANCE` retention helpers for finalized PDFs and SAF-T exports. * `imports`: server-side writes and short-lived browser PUT URLs with prefix scope, size cap, checksum, and idempotency. * `exports`: signed GET URLs with response content headers and an HMAC-covered audit reason. * `data-rights`: export artifact writes/downloads where the ERP owns expiry and retention-vs-erasure conflict handling. * `support-bundles`: `redactedSupportBundleWrite`; redact before upload, because the SDK does not inspect payload bytes. Governance mode is not the fiscal recipe. Lockwell's native and S3 object-lock tests prove active retention cannot be shortened or deleted before the retain-until date, and governance bypass headers are rejected. Signed-URL audit reasons are signed but not encrypted in the URL token; use stable job/document references, not secrets, raw personal data, or sensitive free text. For ERP deployments where the backend uses an internal service URL but the browser reaches a different public origin, set `signedUrlPublicOrigin` on the kit builder; set `signedUrlMaxTtl` for a client-side TTL cap. Both are optional. See [signed URLs](/guide/signed-urls#separate-internal-and-public-origins). Reach the underlying admin client via `kit.admin()` for operations the kit does not wrap. See [the app kit guide](/guide/app-kit). ### ERP live conformance The production test harness includes a Java ERP live conformance runner that composes `LockwellKit`, `LockwellAdminClient`, and `LockwellNativeClient` against the packaged Lockwell image. It covers the minimum TangibleShift cutover flow: idempotent provisioning, object-lock fiscal writes, signed browser GET/PUT URLs, large streaming artifacts, typed quota and credential errors, tenant-disable denial, and audit request-id correlation. Backup and restore remain operational CLI drills (`backup-plan`, `backup-verify`, restore dry-run/verify) documented in `docs/backup-restore.md`, not Java SDK helper APIs. ## Spring Boot starter The `com.lockwell.sdk.spring` package auto-configures a `LockwellClient` and a `LockwellAsyncClient` bean when the `lockwell.*` properties are present. Spring is an optional dependency, so non-Spring consumers never pull it in and the core SDK keeps its zero-runtime-dependency profile. ```yaml # application.yml lockwell: endpoint: https://objects.example.com access-key-id: ${LOCKWELL_ACCESS_KEY_ID} secret-key: ${LOCKWELL_SECRET_KEY} # user-agent: my-service/1.0 # optional override ``` ```java @Service public class ReportService { private final LockwellClient lockwell; public ReportService(LockwellClient lockwell) { this.lockwell = lockwell; } // ... } ``` The supported properties are `endpoint`, `access-key-id`, `secret-key`, and the optional `user-agent`. Both beans are `@ConditionalOnMissingBean`, so an application-defined client always wins. The S3-style starter above is distinct from the opt-in LNW/1 starter. See \[Spring Boot Native Wire] (/sdks/java-spring-wire) for `lockwell.native-wire.*` properties, JDK 25/Spring Boot 4.1.1 compatibility, and the server-only binary streaming contract. LNW does not change this core client's Java 21 bytecode or HTTP JSON behavior. ## Health and readiness `LockwellNativeClient` and `LockwellAdminClient` expose `healthz()`, `healthzAsync()`, `readyz()`, and `readyzAsync()`. They return typed `HealthResult` components and do not send credentials to probe endpoints. These are operational probes, not substitutes for an authenticated request. See [operations and observability](/guide/operations-and-observability). ## Not supported (by design) 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 501 server-side). The Lockwell server and this first-party client support SSE-C and copy-source SSE-C with raw 32-byte customer-key options on object, copy, and multipart operations. The S3 `LockwellClient` exposes presigned GET/PUT/HEAD/DELETE object operations. --- --- url: /sdks/java-native.md description: >- Production notes for the Java LockwellNativeClient, including bearer-token management, safe native retries, Retry-After handling, request timeouts, and TangibleShift ERP-style integration defaults. --- # Java HTTP-native compatibility client Use `LockwellNativeClient` when a Java or Spring service needs the versioned HTTP/JSON compatibility data plane at `/api/v1/`. It is intentionally distinct from the primary binary LNW/1 client in `com.lockwell.sdk.springwire.LockwellNativeWireClient`; it does not silently switch transports. Both surfaces use the same tenant isolation, encryption, quota, retention, audit, and scope checks as the S3 surface. This page is the production checklist for JVM services such as TangibleShift ERP. The full method reference remains on the [Java SDK page](/sdks/java). ## Install ```xml com.lockwell lockwell-sdk 0.2.2 ``` The package targets JDK 25. For the binary transport and Spring Boot 4.1 autoconfiguration, use the `com.lockwell:lockwell-spring-boot-starter` dependency and see the [Java SDK wire section](/sdks/java#lnw1-native-wire). ## Configure the client ```java import com.lockwell.sdk.RetryPolicy; import com.lockwell.sdk.nativeapi.LockwellNativeClient; import java.net.http.HttpClient; import java.time.Duration; LockwellNativeClient nativeClient = LockwellNativeClient.builder() .endpoint("https://objects.example.com") // public listener; /api/v1 is added by the SDK .accessKeyId(System.getenv("LOCKWELL_ACCESS_KEY_ID")) .secretKey(System.getenv("LOCKWELL_SECRET_KEY")) .httpClient(HttpClient.newHttpClient()) .requestTimeout(Duration.ofSeconds(30)) .retryPolicy(RetryPolicy.defaults()) .build(); ``` `endpoint` is the public object listener, not the admin listener. The client appends `/api/v1` itself, so pass the base origin such as `https://objects.example.com`. ## Token lifecycle The native client accepts the same access-key id and secret used by the S3 clients. It mints a short-lived bearer token with `POST /api/v1/auth/token`, caches it until shortly before expiry, and refreshes it under a single-flight lock so concurrent callers share one in-flight mint. If a request receives `401`, the client forces one token re-mint and replays that request once inside the current attempt. This means application code should not cache bearer tokens separately. Store the access-key id and secret in the service secret store, build one shared client per tenant credential set, and let the SDK handle token refresh. ## Native retries The Java native client uses `RetryPolicy.defaults()` by default. It retries transient failures only when the request can be replayed safely: | Request shape | Retried by default? | Why | | ------------------------- | ------------------- | ------------------------------------------------- | | `GET`, `HEAD`, `DELETE` | Yes | Idempotent HTTP methods. | | `PUT` and `POST` writes | Only with a key | Requires an `Idempotency-Key` header. | | Streaming upload supplier | Only when keyed | The supplier must be able to open a fresh stream. | | Other methods | No | Not known to be replay-safe. | Transient failures are transport errors, `429`, and `5xx` responses. A server `Retry-After` header is honored when it is present as delta-seconds or an HTTP date. The SDK never lets `Retry-After` shorten the local backoff, and it caps the server-requested wait so one bad peer cannot hold the caller forever. Turn retries off when a caller owns the retry loop: ```java LockwellNativeClient oneAttempt = LockwellNativeClient.builder() .endpoint(endpoint) .accessKeyId(accessKeyId) .secretKey(secretKey) .retryPolicy(RetryPolicy.disabled()) .build(); ``` ## Write idempotency For `putObject`, pair the idempotency key with a checksum. The key makes a retry replay-safe, and the checksum lets the server prove the replayed body is the same payload before it collapses the duplicate. ```java import com.lockwell.sdk.nativeapi.NativeTypes.PutOptions; import java.security.MessageDigest; import java.util.Base64; byte[] body = invoiceJson.getBytes(java.nio.charset.StandardCharsets.UTF_8); String sha256 = Base64.getEncoder().encodeToString( MessageDigest.getInstance("SHA-256").digest(body)); nativeClient.putObject("erp-documents", "invoices/2026-0001.json", body, new PutOptions() .contentType("application/json") .idempotencyKey("invoice-2026-0001") .checksum("sha256", sha256)); ``` For `Supplier` uploads, the supplier must be repeatable. A supplier that opens a file path is replayable; a supplier that returns an already-consumed stream is not. ## Request timeouts `requestTimeout(Duration)` sets the JDK `HttpRequest.timeout` for each HTTP attempt. If retries are enabled, total wall time can include more than one request timeout plus retry backoff. For large uploads, size the timeout for the full upload attempt. For streaming downloads, the timeout covers the request until the response arrives; the caller owns the pace of reading and closing the returned stream. Use a shared `HttpClient` if your service has strict TLS, proxy, or pooling requirements: ```java HttpClient http = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); LockwellNativeClient nativeClient = LockwellNativeClient.builder() .endpoint(endpoint) .accessKeyId(accessKeyId) .secretKey(secretKey) .httpClient(http) .requestTimeout(Duration.ofSeconds(30)) .build(); ``` Streaming native PUT, GET, and multipart part overloads accept a `ProgressListener`. `TransferProgress` reports cumulative bytes, known or unknown totals, direction, and part number; listener execution applies backpressure and listener failure cancels the source/response. The native builder also accepts `responseMetadataListener` for successful request-id and trace headers. One-shot streams are not transparently resumed; use range/ETag or multipart list/abort plus application-owned atomic files when recovery is needed. ## ERP defaults For systems that write business documents, exports, invoices, attachments, or restore artifacts: * Use the native client for new Lockwell integrations and the S3 client only for S3-porting work. * Keep one shared client per tenant credential set instead of rebuilding a client per request. * Set `requestTimeout` explicitly. * Keep the native retry default unless the service has its own bounded retry framework. * Add an idempotency key and checksum to every write that might be retried. * Use `ifAbsent()` for create-only writes and `ifMatch(etag)` for optimistic updates. * Close every `GetResult` in a try-with-resources block. ## Signed-URL constraints for browser-direct flows When the ERP mints a signed URL for a browser upload or download, pin the properties the browser cannot be trusted to send correctly. Pass `SignedUrlOptions` to `signUrl` / `signUrlResult` (or to the kit's `signedUploadUrl` / `signedDownloadUrl`): `contentType` (pins the stored type), `contentLengthMax` (rejects oversize), `checksum` (verifies the body), `idempotencyKey` (collapses retries), `keyPrefix` (PUT-only prefix scope), and the GET `responseContentType` / `responseContentDisposition` overrides. These are HMAC-covered and enforced by the server at dispatch time. See [signed URLs](/guide/signed-urls) for the full set. For ERP deployments with separate internal and browser-reachable origins, set `signedUrlPublicOrigin` on the app kit builder; set `signedUrlMaxTtl` for a client-side TTL cap. ## ERP live conformance The production test harness now includes a Java ERP live conformance runner. It composes the Java app kit, admin client, and native client against the packaged Lockwell image to prove the minimum cutover flow: idempotent tenant provisioning, object-lock fiscal writes, signed browser GET/PUT URLs, large streaming artifacts, typed denial/error mapping, rotate/revoke and tenant-disable denial, and audit request-id correlation. Backup and restore remain operational CLI drills (`backup-plan`, `backup-verify`, restore dry-run/verify) documented in `docs/backup-restore.md`, not Java native SDK helpers. ## Related pages * [Java SDK](/sdks/java) for the complete package reference. * [Errors and retries](/guide/errors-and-retries) for cross-language retry behavior. * [Conditional writes and idempotency](/guide/conditional-writes) for write safety patterns. * [Upload and download](/guide/data-operations) for object I/O examples. --- --- url: /sdks/bun-native.md description: Server-only @kelphect/sdk-native LNW/1 client for Node 22+ and Bun 1.4+. --- # `@kelphect/sdk-native` (Node and Bun) `@kelphect/sdk-native` 0.1.0 is the shared server-only TypeScript client for Lockwell Native Wire v1. It uses raw TLS 1.3, deterministic binary frames, bounded multiplexing, and the credential proof defined by the \[LNW reference] (/reference/native-wire). It never sends HTTP, JSON, XML, or S3 on this transport and never silently falls back. The package is source-shipped and test-qualified in this repository. Publication to a customer registry is a separate release decision; pin the exact package version and server commit together in an application lockfile. ## Runtime and exports | Runtime | Minimum | Entry | | --- | --- | --- | | Node | 22.0.0 | `@kelphect/sdk-native/node` or the root `node` condition | | Bun | 1.4.0 | `@kelphect/sdk-native/bun` or the root `bun` condition | | Browser / default bundler condition | unsupported | throws a credential-free denial | `/protocol` exposes append-only constants and registries. `/testing` exposes the codec and injectable socket boundary for conformance tests. Keep all client construction, credentials, and imports in server-only modules. ## Install and connect ```sh npm install @kelphect/sdk-native@0.1.0 # or: bun add @kelphect/sdk-native@0.1.0 ``` ```ts import { createLockwellClient } from "@kelphect/sdk-native"; const lockwell = createLockwellClient({ host: process.env.LOCKWELL_NATIVE_HOST ?? "lockwell.internal", port: Number(process.env.LOCKWELL_NATIVE_PORT ?? "9444"), tls: { // The issuing CA is public configuration, not a private key. ca: process.env.LOCKWELL_NATIVE_CA, }, credentials: async () => ({ accessKeyId: process.env.LOCKWELL_ACCESS_KEY_ID!, secretKey: process.env.LOCKWELL_SECRET_KEY!, }), pool: { min: 1, max: 4, acquireTimeoutMs: 5_000, idleTimeoutMs: 30_000 }, }); await lockwell.connect({ timeoutMs: 5_000 }); const result = await lockwell.putObject({ bucket: "private", key: "a.bin", body: new Uint8Array([1, 2, 3]), contentLength: 3, options: { idempotencyKey: crypto.randomUUID(), timeoutMs: 10_000 }, }); console.log(result.etag); await lockwell.close(); ``` Use `tls.ca` for a private PKI. Hostname verification and TLS 1.3 cannot be disabled. mTLS requires `tls.cert` and `tls.key` together. Credential providers run for every new connection, allowing rotation without rebuilding the client. ## Operations and streaming The typed client covers capabilities/readiness; bucket CRUD and versioning; object PUT/GET/HEAD/DELETE, ranges, conditions, ordered metadata, checksums, list/pagination and copy; versions/delete markers; multipart create, upload, list, completion/resume discovery, abort and upload listing; tags; retention and legal hold; batch delete; bucket CORS and webhook notifications; and signed GET/PUT capabilities. SSE-C and ADMIN are not advertised or implemented on this access-key wire. `getObject` returns a `ReadableStream` and a completion promise. Consume the body to release a stream back to the pool. `putObject` and `uploadPart` accept `Uint8Array`, `ArrayBuffer`, `Blob`, `ReadableStream`, or an async iterable; provide the exact `contentLength`. Progress callbacks report transferred bytes. One-shot streams are never replayed implicitly. ```ts const download = await lockwell.getObject({ bucket: "private", key: "a.bin", range: { start: 0, endInclusive: 1023 }, onProgress: (transferred, total) => console.log({ transferred, total }), }); for await (const chunk of download.body) consume(chunk); await download.completed; ``` `RequestOptions` supports `signal`, an absolute `deadline` or `timeoutMs`, an idempotency key, and a validated `traceparent`. Diagnostics expose state, open/idle connections, active streams, and negotiated capabilities without returning credentials or TLS material. ## Authentication, errors, and retries Every connection performs HELLO/WELCOME and an access-key HMAC proof with a fresh timestamp and nonce. Duplicate `AUTH_REPLAY` is terminal. A bounded replay-capacity `RATE_LIMITED` response may be retried only by discarding the connection, resolving credentials again, and generating a fresh proof before the caller deadline. Application retries are limited to read-safe operations or writes with an idempotency key and replayable body; `AUTH_REPLAY`, scope denials, malformed hints, and streaming bodies are not retried. `AbortSignal` cancellation and deadlines stop the active stream. The client reports stable `LockwellError` subclasses (`ProtocolError`, `TransportError`, `AuthenticationError`, `AuthorizationError`, `ServiceError`, `CancelledError`, `DeadlineExceededError`, and `ClientClosedError`). Error text is bounded and redacted; log sinks receive no secret, token, payload, or filesystem path. ## Framework and browser boundaries Framework adapters must own the lifecycle at a server/Nitro boundary. Use [SolidStart](/sdks/solidstart) for the tested SolidStart v2 integration. The browser condition and the package default export intentionally throw, and the package contains no browser socket or credential code. Do not import it from client components, static builds, Cloudflare Workers, or an unknown serverless target. ## Verification The source README, unit tests, byte fixtures, malformed-frame tests, packed consumer, and live Go TLS tests are the authority. Re-run the package checks from `sdk/typescript-native` before changing a capability claim: ```sh bun run typecheck bun test --timeout 15000 bun run build bun run pack:check ``` The [capability index](/reference/sdk-capabilities) records which framework and base-SDK adapters are merged. Go's standalone LNW client, Node's primary-LNW transport, Java shared-core LNW client, and Nuxt remain pending source-owner PRs and are not claimed by this page. The merged Next.js adapter is documented separately in the [Next.js guide](/sdks/nextjs). --- --- url: /sdks/solidstart.md description: >- SolidStart v2 server integration for Lockwell LNW/1 with Node, Bun, and supported Nitro presets. --- # `@kelphect/sdk-solidstart` `@kelphect/sdk-solidstart` 0.1.0 is the server-only SolidStart v2 integration for the shared [`@kelphect/sdk-native`](/sdks/bun-native) LNW/1 client. It adds Vite/runtime guards, request and response streaming, server-function helpers, lifecycle ownership, and redacted diagnostics. It does not encode frames, open sockets, or provide a JSON/S3 fallback. ## Support matrix | Build or deployment | Status | | --- | --- | | Built server on Node 22+ | supported | | Built server on Bun 1.4+ | supported | | Nitro `node_server` / `node_cluster` | supported | | Nitro `bun` | supported | | SolidStart v2 build/dev toolchain | Node 24+ currently required | | Cloudflare, Netlify, edge, static, browser, unknown Nitro preset | refused before application modules load | The package is source-shipped and test-qualified; pin `@kelphect/sdk-native` 0.1.0 alongside the adapter and verify the server commit. Edge targets remain refused until an authenticated binary streaming transport is qualified there. ## Install and guard the build ```sh npm install @kelphect/sdk-solidstart@0.1.0 @kelphect/sdk-native@0.1.0 ``` Only the root package is safe in universal Vite configuration. Put the guard next to SolidStart and Nitro, and make the declared preset match the resolved preset: ```ts import { solidStart } from "@solidjs/start/config"; import { nitro } from "nitro/vite"; import { defineConfig } from "vite"; import { lockwellSolidStart } from "@kelphect/sdk-solidstart"; export default defineConfig({ plugins: [ solidStart(), lockwellSolidStart({ solidStartVersion: "2.0.4", deployment: { target: "nitro", preset: "node_server" }, }), nitro(), ], nitro: { preset: "node_server" }, }); ``` The guard rejects provider edge plugins, static builds, mismatched Nitro declarations, and unsupported runtime floors with `LOCKWELL_SOLIDSTART_UNSUPPORTED_RUNTIME`. There is no catch-and-fallback path. ## Server-only client and routes Import runtime helpers from `@kelphect/sdk-solidstart/server` (or `/node` and `/bun`) in `*.server.ts` modules. That entry carries SolidStart's `server-only` marker and resolves to a throwing denial module under the browser condition. ```ts import env from "env:server/runtime"; import { createNodeLockwellSolidStartClient } from "@kelphect/sdk-solidstart/node"; export const lockwell = createNodeLockwellSolidStartClient({ instanceName: "web", lifecycle: import.meta.env.DEV ? "development" : "production", host: env.LOCKWELL_NATIVE_HOST ?? "localhost", port: Number(env.LOCKWELL_NATIVE_PORT ?? "9444"), tls: { ca: env.LOCKWELL_NATIVE_CA, serverName: "lockwell.internal" }, credentials: () => ({ accessKeyId: env.LOCKWELL_ACCESS_KEY_ID!, secretKey: env.LOCKWELL_SECRET_KEY!, }), pool: { min: 0, max: 16, acquireTimeoutMs: 10_000, idleTimeoutMs: 30_000 }, retry: { maxAttempts: 3, baseDelayMs: 50, maxDelayMs: 2_000 }, requestTimeoutMs: 30_000, }); ``` The public server surface includes `bindLockwellRequest`, `createObjectRouteHandlers`, `getObjectResponse`, `headObjectResponse`, `putObjectResponse`, `uploadPartResponse`, `runLockwellServerFunction`, `runCurrentLockwellServerFunction`, and `runLockwellAction`. Object routes preserve `ReadableStream` backpressure, canonical `Content-Length`, a single explicit byte range, request disconnect cancellation, validated `traceparent`, and bounded idempotency keys. Keep streamed bodies in API routes; server-function values are serialized. The process-local lifecycle single-flights setup, survives development HMR while refreshing credential and telemetry providers, and closes once during production disposal. Changing non-secret configuration under the same `instanceName` fails with a stable collision error instead of reusing the wrong pool. Metrics, tracing, retry, logger, pool, TLS, host, port, and `requestTimeoutMs` are forwarded to the shared client rather than implemented a second time. ## Security and errors TLS hostname validation stays enabled; provide a private CA and set `serverName` to the certificate identity. Browser, static, edge, Cloudflare, Netlify, and unknown Nitro targets are denied. Error responses are bounded, no-store DTOs with stable native error codes; stacks, credentials, filesystem paths, and payloads are not reflected. Health and metrics are aggregate and should be protected by the deployment's operational policy. The adapter inherits LNW upload admission (default process-wide 128), per-stream idle/duration bounds, flow control, Object Lock, ordered user metadata, checksums, ranges, multipart, tags, CORS, notifications, and signed capabilities from the shared client. SSE-C and ADMIN are absent. Choose S3 explicitly for SSE-C; never switch transports in an exception handler. ## Example and evidence The runnable external application is [`examples/solidstart-native`](https://github.com/RusticStack/lockwell/tree/main/examples/solidstart-native): * `src/routes/api/objects/[...path].ts` handles GET, HEAD, range GET, and PUT streams. * `src/routes/api/multipart/[uploadId]/[partNumber]/[...path].ts` handles streamed parts. * `src/actions.ts` demonstrates list, copy, version, multipart, retention, and legal-hold server actions. Adapter tests cover runtime/preset denial, configuration, single-flight/HMR lifecycle, rotation, interrupted streams, ranges, size bounds, malformed errors, packed NodeNext consumers, browser denial, and secret/native-code scans. Oracle ARM64 Node/Bun live TLS and external SolidStart builds passed. The representative lifecycle benchmark (Node LNW 54.3 vs S3 77.2 MiB/s; Bun LNW 71.2 vs S3 126.6 MiB/s) is a bounded process-local sample, not a storage, durability, latency-SLO, or production-throughput claim. Local Windows Bun returned `UNAVAILABLE` for the stalled-handshake stress shape and is not part of the current qualification evidence. Run the adapter checks from `sdk/solidstart`: ```sh npm run test:node npm run test:bun npm run test:distribution ``` Read [the complete SolidStart contract](https://github.com/RusticStack/lockwell/blob/main/docs/solidstart-native-sdk.md) for migration/rollback and the exact evidence ledger. The merged Next.js adapter has its own [framework guide](/sdks/nextjs); Nuxt remains open source-owner work and is not claimed here. Rust is an explicit first-party SDK non-goal. --- --- url: /sdks/nextjs.md description: Server-only Next.js 16.3 adapter for Lockwell LNW/1 on Node 22+ and Bun 1.4+. --- # `@kelphect/sdk-nextjs` `@kelphect/sdk-nextjs` is the merged, server-only Next.js adapter for Lockwell's experimental LNW/1 binary data plane. It composes the shared [`@kelphect/sdk-native`](/sdks/bun-native) client; it does not duplicate the codec and it never falls back to HTTP, JSON, XML, or S3. Existing S3 applications should keep using [`@kelphect/sdk`](/sdks/node) and migrate route-by-route. ## Support boundary | Deployment or surface | Status | Tested contract | | --- | --- | --- | | Next.js 16.3.3 through 16.x | supported | App Router Server Components, Server Actions, and Node Route Handlers | | Node.js 22+ | supported | `next start`, standalone/container output, and representative Node serverless warm processes | | Bun 1.4+ | supported | Node-compatible Next runtime with the shared Bun socket condition | | Client Components and browser bundles | refused | build-time `server-only`/browser condition denial; no credentials or native socket code | | Edge Route Handlers and middleware | refused | socket-free `edge-light` export raises `LOCKWELL_UNSUPPORTED_RUNTIME` | The adapter is for Node-compatible server execution. Add `export const runtime = "nodejs"` to routes that import it; Edge cannot open the authenticated raw TLS socket. No compatibility fallback is attempted. The package is a source and test contract in this repository; no package publication is implied. ## Install and configure ```sh npm install @kelphect/sdk-nextjs @kelphect/sdk-native server-only ``` Keep all endpoint and credential values server-only. `lockwellConfigFromEnv()` rejects every `NEXT_PUBLIC_LOCKWELL_*` variable, and `LockwellSecret` redacts string and JSON conversion: ```dotenv LOCKWELL_NATIVE_HOST=storage.internal LOCKWELL_NATIVE_PORT=9444 LOCKWELL_ACCESS_KEY_ID=tenant-key LOCKWELL_SECRET_KEY=replace-me LOCKWELL_TLS_CA=/run/secrets/lockwell-ca.pem # Optional mTLS: provide both values, never only one. LOCKWELL_TLS_CERT=/run/secrets/client.crt LOCKWELL_TLS_KEY=/run/secrets/client.key LOCKWELL_TLS_SERVER_NAME=storage.internal LOCKWELL_LIFECYCLE=auto LOCKWELL_TIMEOUT_MS=30000 ``` The typed alternative is `createNextLockwell(config)` with `NextLockwellCredentials`, `LockwellSecret`, optional credential provider, TLS CA/server name, and an atomic mTLS certificate/key pair. `getNextLockwell(config)` owns a Fast Refresh-safe process accessor; `disposeNextLockwell()` drains that owned pool. Use `lifecycle: "long-lived"` for `next start`/containers or `lifecycle: "serverless"` for a small lazy warm pool. Neither lifecycle takes ownership of the application's SIGTERM/SIGINT handlers. ## Streaming route handler The adapter forwards a Web `ReadableStream` to LNW/1 without buffering the object in memory. A bounded upload requires a valid `Content-Length` header (or explicit `contentLength`), propagates the request abort signal, and accepts validated `traceparent` and `Idempotency-Key` context: ```ts import "server-only"; import { lockwellErrorResponse } from "@kelphect/sdk-nextjs"; import { lockwell } from "@/lib/lockwell"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function PUT(request: Request, { params }: { params: Promise<{ key: string[] }> }) { try { const storage = await lockwell(); const result = await storage.putRequest(request, { bucket: "app-private", key: (await params).key.join("/"), request: { signal: request.signal, timeoutMs: 120_000 }, cache: { mode: "revalidate", namespace: "tenant-from-auth" }, }); return Response.json({ etag: result.etag, versionId: result.versionId }, { status: 201 }); } catch (error) { return lockwellErrorResponse(error); } } ``` `NextLockwellAdapter.getResponse()` returns the native response stream, supports bounded single ranges, and emits private/no-store headers by default. `headCached()` caches metadata only under a stable tenant/principal namespace; object bytes are never put in Next's server cache. ## Cache, lifecycle, and observability Use `expireLockwellObject()` in a Server Action for immediate read-your-writes, or `revalidateLockwellObject()` in a Route Handler for stale-while-revalidate behavior. Tags hash the namespace, bucket, key, and optional version so a request-supplied bucket name cannot cross a tenant boundary. The package exports `lockwellRequestContext()` for cancellation, traceparent, and idempotency propagation, `lockwellDeploymentDiagnostics()`/`redactedConfig()` for safe runtime diagnostics, and `registerLockwellInstrumentation()`/`lockwellTraceSink()` for optional metrics and OpenTelemetry-compatible spans. `safeLockwellError()` and `lockwellErrorResponse()` return bounded, no-store errors; unknown exceptions become a generic 500\. Diagnostics and errors exclude credentials, tokens, certificates, payloads, and filesystem paths. ## Data and failure semantics The adapter exposes the shared native operations through `adapter.client`: bucket and object CRUD, ranges and streaming, copy, pagination, multipart resume/abort, versions/delete markers, tags, retention, legal hold, and signed capabilities. LNW capability negotiation, checksums, upload bounds, retryability, cancellation, and tenant authorization remain owned by the shared SDK/server. Application operations are never silently replayed; one-shot streaming bodies require a caller-owned recovery strategy. SSE-C is not an LNW capability: SSE-C-shaped headers are rejected before I/O, so use the documented S3 surface when customer-provided keys are required. Governance bypass and the Admin wire surface are absent. ## Verification and example The runnable [`examples/nextjs-native-wire`](https://github.com/RusticStack/lockwell/tree/main/examples/nextjs-native-wire) fixture covers App Router actions, streaming PUT/GET/HEAD/DELETE, bounded ranges, multipart, checksums, versioning, retention/legal-hold denial, interrupted streams, cache invalidation, standalone output, serverless warm reuse, and Edge/Client Component denial. The package contract is documented in [`docs/nextjs-native-sdk.md`](https://github.com/RusticStack/lockwell/blob/main/docs/nextjs-native-sdk.md) and its tests under [`sdk/nextjs/test`](https://github.com/RusticStack/lockwell/tree/main/sdk/nextjs/test). ```sh cd sdk/nextjs && npm ci && npm run check cd ../../examples/nextjs-native-wire && npm ci && npm run build && npm run verify && npm run verify:serverless ``` The merged adapter's Oracle ARM64 evidence covers Node and Bun live TLS workflows, standalone/container and representative serverless builds, packed consumers, browser/Edge denial scans, quality, supply-chain, and bounded concurrency. Its benchmark is a single-host measurement, not a general performance or hosted-provider claim. ## Migration and rollback Adopt one Server Component, Action, or Route Handler at a time while keeping the existing S3/HTTP-native path explicit. There is no automatic fallback. Rollback removes the adapter import, drains its owned pool with `disposeNextLockwell()`, and restores the prior route; object bytes, versions, retention, and legal holds require no migration. --- --- url: /sdks/nuxt.md description: >- Use Lockwell's binary LNW/1 data plane safely from Nuxt 4.5+ and supported Nitro Node or Bun servers. --- # Nuxt 4.5+ Native Wire `@kelphect/nuxt-lockwell` is the server-only Nuxt adapter for the shared `@kelphect/sdk-native` LNW/1 client. It owns Nuxt configuration, Nitro lifecycle, request cancellation, HTTP streaming helpers, health diagnostics, and optional OpenTelemetry bridging. It does not copy the wire codec and never falls back to JSON, XML, or S3. ::: warning Release boundary LNW/1 remains opt-in. Use the source-shipped package only with the matching Lockwell server revision until the release gate and package-publication policy explicitly approve a public version. ::: ## Install ```sh npm install @kelphect/nuxt-lockwell @kelphect/sdk-native ``` Use Nuxt 4.5 or newer with Node 22+ or Bun 1.4+. Configure public connection settings in `nuxt.config.ts`, but keep credentials and TLS private keys in server runtime configuration: ```ts export default defineNuxtConfig({ modules: ["@kelphect/nuxt-lockwell"], lockwellNative: { host: "lockwell.internal", port: 9444, requestTimeoutMs: 30_000, tls: { serverName: "lockwell.internal", caFile: "/run/secrets/lockwell-ca.pem", }, pool: { maxConnections: 8, idleTimeoutMs: 30_000, acquireTimeoutMs: 10_000, }, }, }) ``` Supply credentials only through private runtime environment variables: ```text NUXT_LOCKWELL_NATIVE_ACCESS_KEY_ID=... NUXT_LOCKWELL_NATIVE_SECRET_ACCESS_KEY=... ``` Putting `lockwellNative` under `runtimeConfig.public` fails the build. TLS certificate verification remains enabled, and file-backed CA, client-certificate, and private-key material is loaded only by the server process. ## Supported Nitro presets | Preset | Runtime | Status | | --- | --- | --- | | `nitro-dev` | Node | Supported for development; HMR retires the old pool | | `node-server` | Node 22+ | Supported | | `node-cluster` | Node 22+ | Supported; one bounded pool per worker | | `bun` | Bun 1.4+ | Supported | | `aws-lambda`, `netlify`, `vercel` | Serverless | Rejected until raw-TLS lifecycle is qualified | | Cloudflare, edge, Deno, service worker | Edge | Rejected; no raw socket transport | | `static`, `github-pages`, unknown | No supported server | Rejected | Unsupported presets fail during `nitro:config`. There is no hidden JSON or S3 fallback. Selective prerendering inside a supported server build is an application decision; static-only generation is not supported. ## Server helpers Nitro auto-imports server-only helpers: * `useLockwellNative()` returns the shared typed client. * `useLockwellStorage(event)` adds request cancellation, deadlines, and valid inbound trace context. * `defineLockwellEventHandler(handler)` supplies the event-scoped client, service, and cancellation signal. * `uploadLockwellObjectFromEvent` streams a known-length body. * `uploadLockwellMultipartFromEvent` handles bounded unknown-length or resumable uploads with abort cleanup. * `downloadLockwellObjectToEvent` streams full, closed, open, and suffix ranges. ```ts export default defineLockwellEventHandler(async ({ event }) => { return uploadLockwellObjectFromEvent(event, { bucket: "documents", key: getRouterParam(event, "key")!, checksum: { algorithm: "SHA256", value: getHeader(event, "x-lockwell-checksum-sha256")!, }, request: { idempotencyKey: getHeader(event, "idempotency-key") }, }) }) ``` Known-length uploads stream directly. Unknown-length requests must use the multipart helper, which processes sequential 5–512 MiB parts, retains at most one part plus an inbound chunk, and performs best-effort abort cleanup. Direct idempotent streams require a caller-supplied checksum; the adapter never buffers a stream merely to manufacture one. The service exposes the shared core's bucket, object, pagination, multipart, batch-delete, copy, version, tag, retention, legal-hold, CORS, notification, readiness, and signed-capability operations. SSE-C and Admin operations are not LNW/1 data-plane capabilities. ## Lifecycle and diagnostics The native client is lazy and shared per Nitro application. Nitro's `close` hook drains it, and development HMR retires the previous instance before replacement. H3 request cancellation propagates to LNW/1 `CANCEL`; operation deadlines use the smaller caller or configured bound. `GET /api/_lockwell/health` performs native readiness and returns 503 when unavailable. Its fixed response contains only aggregate state, bounded connection counts, negotiated capabilities, and metrics—never credentials, TLS material, peer certificates, object keys, tenant IDs, or payloads. When `@opentelemetry/api` is installed and telemetry is enabled, the adapter uses the application's providers and installs no exporter. ## Migration and rollback Keep S3 and LNW/1 clients explicit while migrating. Compare reads and metadata first, then move idempotent writes, multipart recovery, versions, Object Lock denials, cancellation, and bounded-concurrency workloads. Rollback drains the native pool and routes the application back to its separately configured S3 client; object bytes and metadata require no migration because both transports use the same server-side storage authority. See the [Native Wire guide](/guide/native-wire), [capability index](/reference/sdk-capabilities), and the [source contract](https://github.com/RusticStack/lockwell/blob/main/docs/sdk-nuxt-native.md). --- --- url: /sdks/java-spring-wire.md description: >- Spring Boot 4.1 and JDK 25 Lockwell Native Wire starter with TLS, streaming, Object Lock, and metrics. --- # Spring Boot Native Wire starter `com.lockwell:lockwell-spring-boot-starter` 0.2.2 is the opt-in, JDK 25-first client for LNW/1. It is a separate artifact compiled with `--release 25`; the existing `com.lockwell:lockwell-sdk` remains Java 21 bytecode and continues to provide the S3, HTTP-native JSON, Admin, and kit clients. The starter sends deterministic binary frames only—never HTTP, JSON, or XML—and never falls back to another surface. ## Install ```xml com.lockwell lockwell-spring-boot-starter 0.2.2 ``` This coordinate is source-shipped and consumer-tested in the repository; use your approved internal or GitHub Packages mirror and pin the artifact/checksums. The starter is tested with Spring Boot 4.1.1 and an external compatibility consumer under Spring Boot 4.0.7. ## Configuration Enable the starter explicitly and keep secrets in environment-backed configuration: ```properties lockwell.native-wire.enabled=true lockwell.native-wire.host=lockwell.internal.example lockwell.native-wire.port=9444 lockwell.native-wire.access-key-id=${LOCKWELL_ACCESS_KEY_ID} lockwell.native-wire.secret-key=${LOCKWELL_SECRET_KEY} lockwell.native-wire.tls.ca-certificate=/run/secrets/lockwell-ca.pem # Base64(SHA-256(SubjectPublicKeyInfo)); supplements PKIX + hostname verification. lockwell.native-wire.tls.spki-sha256-pins[0]=${LOCKWELL_SPKI_PIN} # Required together for an optional/required-mTLS listener. lockwell.native-wire.tls.client-key-store=/run/secrets/lockwell-client.p12 lockwell.native-wire.tls.client-key-store-password=${LOCKWELL_CLIENT_KEYSTORE_PASSWORD} ``` Public properties include `host`, `port`, `accessKeyId`, `secretKey`, `poolSize` (1–128, default 4), `connectTimeout` (10 seconds), `readTimeout` (2 minutes), `acquireTimeout` (10 seconds), and nested TLS CA, SPKI pin, PKCS#12 identity, and explicit loopback-plaintext test settings. TLS 1.3 and hostname verification are enforced for non-loopback use. `toString()` and diagnostics redact secret and keystore password material. Spring auto-configuration creates lifecycle-managed `LockwellNativeWireClient`, a virtual-thread `LockwellNativeWireAsyncClient`, and `LockwellNativeWireHealthIndicator` when `lockwell.native-wire.enabled=true`. The health check performs authenticated readiness, including the server metadata-authority/quorum guard. Disable the property to roll back without changing stored objects. ## Java API and streaming The client exposes capabilities/readiness, bucket CRUD/versioning, object PUT/GET/HEAD/DELETE, ranges, ordered duplicate user metadata, checksums, conditions, list/pagination/copy, versions/delete markers, multipart create/upload/list/ complete/abort, tags, retention, legal hold, batch delete, bucket CORS, webhook notifications, and signed GET/PUT capabilities. `ObjectLockWrite` carries optional retention and legal-hold state on the initial PUT; the server rechecks Object Lock scopes and commits the state atomically with the new version. Governance bypass is not supported. Use `getObject(request, OutputStream)` for direct streaming or `withObject(request, handler)` for a bounded 64 KiB backpressured pipe. The callback must consume the stream before returning; closing or abandoning it cancels and invalidates the underlying connection. `TransferProgressListener` reports bytes. `RequestOptions` carries a deadline and validated traceparent. For serializable application work, the virtual-thread async client returns `CompletableFuture` values. The connection pool remains the concurrency bound; an application-supplied executor remains application-owned and is not closed by the client. ## Auth, retry, and telemetry The client performs HELLO/WELCOME and a fresh access-key timestamp/nonce proof for each connection. Only an `AUTH_ERROR` with exact `RATE_LIMITED`, `retryable=true`, and a bounded hint may reopen a fresh socket and regenerate credentials/proof before the caller deadline. `AUTH_REPLAY`, scope/tenant denials, malformed hints, and application operations are never retried implicitly. A streaming write needs a new body, idempotency key, and body-binding checksum for an explicit caller retry. Micrometer meters use bounded labels only: | Meter | Labels / meaning | | --- | --- | | `lockwell.native.wire.requests` | `result=success`, `result=failure`, or `result=cancelled` | | `lockwell.native.wire.request.duration` | request timer | | `lockwell.native.wire.connections` | aggregate active connections | | `lockwell.native.wire.bytes` | `direction=inbound` or `direction=outbound`; header + metadata + payload + CRC after a successful write/validated read | Tracing accepts a `TraceparentProvider`; diagnostics and errors are redacted. Webhook signing secrets are returned once; configure a credential-free HTTPS URL and HMAC headers rather than putting a token in a query string. ## Capability boundary SSE-C is deliberately not advertised by this starter: critical SSE-C fields are rejected and same-named user metadata is still user-owned, so metadata cannot manufacture internal encryption state. Use the S3 client for genuine SSE-C. The Admin capability is a separate unimplemented authentication surface. Browser, edge, and static runtimes are not starter targets. ## TangibleShift-shaped example The public consumer fixture under [`sdk/java-spring-boot-starter/consumer-tests/maven`](https://github.com/RusticStack/lockwell/tree/main/sdk/java-spring-boot-starter/consumer-tests/maven) uses company-scoped keys and exercises immutable conditional writes, ranges, copy/list, multipart resume/abort, versions, batch delete, CORS, webhook configuration, retention/legal hold, and signed reads. A service can keep the same shape without copying private application code: ```java import com.lockwell.sdk.springwire.LockwellNativeWireClient; import com.lockwell.sdk.springwire.NativeWireTypes; import java.io.InputStream; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Optional; import org.springframework.stereotype.Service; @Service final class ArtifactStore { private final LockwellNativeWireClient lockwell; ArtifactStore(LockwellNativeWireClient lockwell) { this.lockwell = lockwell; } void write(String company, InputStream body, long length) { var request = new NativeWireTypes.PutObjectRequest( company + "-artifacts", "immutable/report.pdf", length, "application/pdf", List.of(), Optional.empty(), Optional.empty(), false, Optional.empty(), Optional.empty(), Optional.of(NativeWireTypes.ObjectLockWrite.retention( NativeWireTypes.RetentionMode.COMPLIANCE, Instant.now().plus(30, ChronoUnit.DAYS))), NativeWireTypes.RequestOptions.defaults()); lockwell.putObject(request, body); } } ``` Adapt the bucket naming, authorization, and retention policy to your tenant model; do not put access-key secrets in the service source. The exact record constructors may evolve with the starter contract, so compile against the pinned artifact and consult `NativeWireTypes` in source. ## Verification and rollback Run the starter's Maven tests and external Maven/Gradle consumers with JDK 25 before changing a claim. The cross-language fixture proves byte compatibility with the Go server, comma/equals/reserved-name metadata round trips, and that native wire metadata cannot trigger SSE-C. Disable `lockwell.native-wire.enabled`, drain clients, and restore the existing explicit S3 or HTTP-native JSON selection to roll back; no object migration is performed. See [LNW architecture](/guide/native-wire), the [wire reference](/reference/native-wire), and the [source README](https://github.com/RusticStack/lockwell/blob/main/sdk/java-spring-boot-starter/README.md) for the full protocol, evidence, and compatibility ledger. --- --- url: /reference/s3-operations.md description: >- The full matrix of S3 operations the Lockwell data-plane client supports, with key options, shared by the Go, Node, and Java SDKs. --- # S3 operations reference The S3 data-plane client implements the S3 API surface that Lockwell supports. If you have used the AWS SDK, the operation names and option names will look familiar. This page lists every operation in one place so you can see the full breadth at a glance. Each operation is available in the Go, Node, and Java SDKs with matching names. For the JSON alternative (bearer tokens instead of SigV4, no XML), see the [native data-plane API](/reference/native-api). For tenant and key management, see the [Admin API](/reference/admin-api). ## Buckets | Operation | Purpose | Key options | | --------------------- | ----------------------------------------------- | ------------------------------- | | `CreateBucket` | Create a private bucket | Object Lock enabled at creation | | `HeadBucket` | Check that a bucket exists and you can reach it | | | `DeleteBucket` | Delete an empty bucket | | | `PutBucketVersioning` | Enable or suspend versioning | `Enabled`, `Suspended` | | `GetBucketVersioning` | Read the versioning state | | Buckets are always private. There is no public-bucket or anonymous-access toggle. ::: info A public-bucket or anonymous-access toggle is a deliberate non-goal. Share objects with a presigned GET URL or a native signed URL instead. ::: ## Objects: write | Operation | Purpose | Key options | | --------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------- | | `PutObject` | Write an object from a buffer or a stream | content type, user metadata, idempotency key, checksum algorithm, SSE-S3, retention, legal hold | | `CopyObject` | Server-side copy within or across buckets | metadata directive (COPY or REPLACE), copy-source conditionals, SSE | | `DeleteObject` | Delete one object or one version | version id | | `DeleteObjects` | Delete up to 1000 objects in one request | quiet mode, per-key errors with partial success | `PutObject` accepts a stream, so you can upload an object larger than memory without buffering it. See [Upload & download](/guide/data-operations). ## Objects: read | Operation | Purpose | Key options | | ------------ | ------------------------------------- | -------------------------------------------------------------- | | `GetObject` | Stream an object body | byte range, version id, part number, response header overrides | | `HeadObject` | Read object metadata without the body | byte range, version id, response header overrides | ## Listing | Operation | Purpose | Key options | | ---------------------- | ---------------------------------------- | ------------------------------------------------------------ | | `ListObjectsV2` | List objects by prefix, token-paged | prefix, delimiter, start-after, continuation token, max keys | | `ListObjects` | List objects, marker-paged (the v1 form) | prefix, delimiter, marker, max keys | | `ListObjectVersions` | List versions and delete markers | prefix, delimiter, key marker, version-id marker, max keys | | `ListMultipartUploads` | List in-progress multipart uploads | prefix, delimiter, key marker, upload-id marker, max uploads | | `ListParts` | List the parts of one multipart upload | part-number marker, max parts | Every listing operation has a paginator that follows the continuation tokens for you: `ListObjectsV2Paginator`, `ListObjectVersionsPaginator`, `ListMultipartUploadsPaginator`, and `ListPartsPaginator`. Each exposes `HasMorePages()` and `NextPage()`. See [Listing & pagination](/guide/listing-objects). ## Multipart uploads | Operation | Purpose | Key options | | ------------------------- | --------------------------------------------------- | ---------------------------------- | | `CreateMultipartUpload` | Start a multipart upload | same write options as `PutObject` | | `UploadPart` | Upload one part | per-part checksum | | `UploadPartCopy` | Fill a part by server-side copy from another object | copy-source range and conditionals | | `CompleteMultipartUpload` | Assemble the uploaded parts into one object | idempotency key | | `AbortMultipartUpload` | Discard an upload and its parts | | See [Multipart uploads](/guide/multipart-uploads) for a full large-file example. ## Tagging | Operation | Purpose | Key options | | --------------------- | -------------------------------- | ----------- | | `PutObjectTagging` | Replace the tag set on an object | version id | | `GetObjectTagging` | Read the tag set | version id | | `DeleteObjectTagging` | Remove all tags | version id | ## Versioning Versioning is controlled with `PutBucketVersioning` and `GetBucketVersioning` (above). Once enabled, every write keeps the prior version, a delete writes a delete marker, and you can read or delete a specific `versionId`. List versions with `ListObjectVersions`. See [Versioning](/guide/versioning). ## Object Lock | Operation | Purpose | Key options | | -------------------- | --------------------------------------------- | ----------- | | `GetObjectRetention` | Read the retention mode and retain-until date | version id | | `GetObjectLegalHold` | Read the legal-hold status | version id | Retention and legal holds are set when you write the object, through the `PutObject` options (retention mode and retain-until date, legal hold on or off). Object Lock must be enabled when the bucket is created. Governance-mode bypass is not supported. See [Object Lock](/guide/object-lock). ## Presigned URLs | Operation | Purpose | Key options | | ------------------ | --------------------------------------------------- | ----------------------------- | | `PresignGetObject` | Build a signed GET URL a browser can fetch directly | expiry (capped by the server) | The Lockwell S3 service accepts query-SigV4 presigned GET, PUT, HEAD, and DELETE requests from compatible external clients. The first-party Go, Node, and Java S3 clients generate presigned GET, PUT, HEAD, and DELETE URLs. To let a browser upload directly with a first-party client, use a native signed PUT URL from the [app kit or native client](/guide/signed-urls). That is a deliberate split: presigned writes on the S3 surface stay off, and the native signed URL is the supported upload path. ## Checksums and integrity Request a checksum on any write with the checksum option, using CRC32, CRC32C, CRC64NVME, SHA-1, or SHA-256. The SDK computes the digest on the client, the server verifies it, and the value comes back on the response. Multipart uploads support a checksum per part. See [Checksums & integrity](/guide/checksums). ## Conditional writes and idempotency The first-party S3 clients support create-only `PutObject` with `If-None-Match: *`. Overwrite-only `If-Match: ` writes use the native client. `CopyObject` does support copy-source conditionals (`If-Match`, `If-None-Match`, `If-Modified-Since`, `If-Unmodified-Since` evaluated against the source object). Set an idempotency key on a write so a retried request is applied once. See [Conditional writes & idempotency](/guide/conditional-writes). ## Retries Construct a client with a retry policy. The default policy makes three attempts with exponential backoff and jitter, and retries idempotent requests (GET, HEAD, DELETE) along with writes that carry an idempotency key. The disabled policy makes a single attempt. See [Errors & retries](/guide/errors-and-retries). ## Server-side encryption Objects are encrypted at rest by default with a per-tenant data key. Request the SSE-S3 server-managed mode explicitly with the encryption option on a write. SSE-KMS is not supported. The S3 wire API supports SSE-C when at-rest encryption is enabled: callers provide the same AES-256 customer key and MD5 headers on every applicable write, read, copy, and multipart request. The first-party Go, Node, and Java S3 clients expose typed SSE-C and copy-source SSE-C helpers. ### Reserved metadata isolation User metadata is lossless, duplicate-preserving, and user-owned. The merged metadata remediation stores it separately from internal SSE-C and Object Lock state, including historical reserved names such as `x-amz-meta-lockwell-sse-customer-key-md5`. Caller-controlled metadata cannot manufacture an internal encryption marker; the same comma/equals/reserved-name round trips are covered through S3, native HTTP, and LNW/1. This does not make SSE-C available on native transports: use the S3 surface for genuine customer-provided keys. ## What the S3 client does not do These are deliberate non-goals. They are not roadmap gaps, and the SDK will not expose them: * Provider-specific POST policies or STS credentials. The typed S3 presigners cover GET, PUT, HEAD, and DELETE; the native client provides constrained GET/PUT URLs for browser-direct flows. * Public or anonymous buckets, public sharing, ACLs. * SSE-KMS. SSE-C and copy-source SSE-C are available on the S3 wire and through typed first-party SDK helpers. * IAM, STS, AssumeRole, and bucket policies (use Lockwell access keys and scopes). * Website hosting, S3 Select, Inventory, Intelligent-Tiering, Object Lambda, S3 Express. * Event notifications on the S3 client (configure them on the [native client](/guide/webhooks) instead). For the reasoning behind these boundaries, see the repository's [final replacement contract](https://github.com/RusticStack/lockwell/blob/main/docs/final-replacement-contract.md). --- --- url: /reference/native-api.md description: >- The wire-level reference for the Lockwell native JSON data-plane API at /api/v1/, including bearer-token auth, routes, signed URLs, and error shapes. --- # Native data-plane API The native JSON data-plane API lives at `/api/v1/` on the public listener: the same host:port as the S3 API, a different path prefix. No SigV4 signing, no XML. JSON in, JSON out. It is a new transport and auth layer over the same domain pipeline the S3 handler uses (the same object coordinator, encryption, dedup, checksums, versioning, object-lock, scope enforcement, quota, and audit). A native write is encrypted-at-rest, deduped, quota-checked, and retention-gated exactly like an S3 write. This page documents the legacy HTTP/JSON surface. Existing integrations can use [Go's explicit compatibility client](/sdks/go#pkg-lockwellnative-the-legacy-httpjson-compatibility-client), [Node](/sdks/node#nativeclient-the-native-client), or [Java](/sdks/java#lockwellnativeclient-the-native-client). New Go applications should use the direct binary [LNW/1 client](/sdks/go#pkg-lockwellwire-the-lnw1-native-wire-client). ## Authentication The native API does not use SigV4 on data calls. A caller exchanges its existing S3 access key for a short-lived native bearer token. ### Mint a token: `POST /api/v1/auth/token` OAuth client-credentials style. Present your S3 access-key id plus secret either as HTTP Basic (`Authorization: Basic base64(accessKeyId:secretKey)`) or a JSON body `{"accessKeyId": "...", "secretKey": "..."}`. On success you get a short-lived bearer token: ```text lwtk_. ``` * TTL is `security.native_api_token_ttl` (default `1h`). Keep it short: a leaked token is replayable until it expires. * The token is stateless and signed (HMAC under a per-deployment key derived from the at-rest master key), so the hot path verifies it with no per-request DB lookup for the token itself. * Revoking or expiring the underlying access key (or disabling the tenant) invalidates outstanding tokens promptly. The verify path re-checks revocation on every request, so a token can never outlive or out-scope the key it points at. * This endpoint accepts the secret once, so it must run behind TLS. It is rate-limited per access-key id and audited (success and failure). Send the token on every subsequent call as `Authorization: Bearer `. The SDKs cache it until shortly before expiry, refresh transparently, and re-mint once on a 401. ::: warning The bearer token is replayable until it expires. Keep its TTL short and always mint it over TLS, since `auth/token` accepts the secret in the clear. ::: ### Signed-URL auth (no bearer token) `GET|PUT /api/v1/signed/{bucket}/{key...}?token=…` is authorized solely by the query `token`: an HMAC-signed, expiring URL token (`lwurl_…`) minted by `POST /api/v1/sign-url`, under a separate per-deployment key. At access time the handler re-checks the underlying key's revocation, re-runs the per-operation scope and bucket-policy gates against the current scope, and enforces that the request method and bucket/key match the signed token. A tampered, expired, wrong-method, wrong-resource, revoked, or scope-exceeding URL is rejected (401/403). See [signed URLs](/guide/signed-urls). Browser preflight for signed URLs uses `OPTIONS /api/v1/signed/{bucket}/{key...}?token=…`. The token still has to authorize the requested method and resource, and the bucket's stored CORS rules still have to match the origin and requested headers. CORS never grants object access by itself. ## Routes All routes except `/healthz`, `/openapi.json`, and `/auth/token` require a valid bearer token. The tenant is taken from the signed token, never the request path, so cross-tenant access is structurally impossible (another tenant's bucket returns `404`, never a leak). ### Buckets | Method + path | Purpose | | ----------------------------------------- | ---------------------------------------------------------- | | `GET /buckets` | list the tenant's buckets | | `POST /buckets` | create a private bucket (versioning / object-lock options) | | `GET /buckets/{bucket}` | get a bucket | | `DELETE /buckets/{bucket}` | delete an empty bucket | | `GET\|PUT /buckets/{bucket}/versioning` | read / set versioning state | | `GET\|PUT\|DELETE /buckets/{bucket}/cors` | read / set / clear browser CORS rules | ### Objects | Method + path | Purpose | | --------------------------------------------- | --------------------------------------------------------------- | | `PUT /buckets/{bucket}/objects/{key...}` | streaming upload | | `GET /buckets/{bucket}/objects/{key...}` | streaming download (`Range` supported) | | `HEAD /buckets/{bucket}/objects/{key...}` | metadata only | | `DELETE /buckets/{bucket}/objects/{key...}` | delete (delete marker in a versioned bucket) | | `GET /buckets/{bucket}/objects` | list (`prefix` / `delimiter` / `maxKeys` / `continuationToken`) | | `POST /buckets/{bucket}/objects:batchDelete` | batch delete, per-key results | | `POST /buckets/{bucket}/object-copy/{key...}` | same-tenant server-side copy | The upload supports several controls: * An `Idempotency-Key` header, mapped onto the same idempotency store the S3 `X-Lockwell-Idempotency-Key` path uses. * Native conditional writes (`If-Match` / `If-None-Match`). * Optional server-side checksum verification (`X-Lockwell-Checksum-` for CRC32, CRC32C, CRC64NVME, SHA1, SHA256). A bad digest is rejected before any bytes are committed. The copy source is the JSON body (`{sourceBucket, sourceKey, sourceVersionId?, metadataDirective?, …, requireAbsent?, requireMatchEtag?}`). Cross-tenant copy is impossible, since the source resolves under the token's tenant. ### Versions, tags, and per-object WORM | Method + path | Purpose | | --------------------------------------------------------- | ----------------------------------------------------------------------------- | | `GET /buckets/{bucket}/versions` | list versions and delete markers (`prefix` / `keyMarker` / `versionIdMarker`) | | `GET\|PUT\|DELETE /buckets/{bucket}/object-tags/{key...}` | get / replace / clear the JSON tag set | | `GET\|PUT /buckets/{bucket}/object-retention/{key...}` | per-object retention (`{mode: GOVERNANCE\|COMPLIANCE, retainUntil}`) | | `GET\|PUT /buckets/{bucket}/object-legal-hold/{key...}` | legal-hold status (`{status: ON\|OFF}`) | These four sub-resources use a distinct path prefix (not a suffix on `/objects/{key...}`) so the key wildcard preserves embedded slashes. ::: info Retention can be extended but never shortened. There is no governance bypass on the native path, a deliberate non-goal that stays closed. ::: ### Multipart | Method + path | Purpose | | ------------------------------------------------------------------------ | ------------------------ | | `POST /buckets/{bucket}/multipart/{key...}` | create an upload | | `PUT /buckets/{bucket}/multipart/{uploadId}/parts/{partNumber}/{key...}` | upload a part | | `GET /buckets/{bucket}/multipart/{uploadId}/parts/{key...}` | list parts | | `POST /buckets/{bucket}/multipart/{uploadId}/complete/{key...}` | complete | | `DELETE /buckets/{bucket}/multipart/{uploadId}/{key...}` | abort | | `GET /buckets/{bucket}/multipart` | list in-progress uploads | ### Signed URLs * `POST /sign-url` (bearer): mint a method-, resource-, and scope-bounded signed URL. * `GET|PUT /signed/{bucket}/{key...}?token=...` (no bearer): use a signed URL. `method` is `GET` (download) or `PUT` (upload): the native API supports signed write URLs. The TTL is clamped to `security.max_presign_ttl`, and the URL can never exceed the minting key's live scope (a read-only key minting a PUT URL is `403`). `POST /sign-url` accepts ```text {method, bucket, key|keyPrefix, ttlSeconds?, contentType?, contentLengthMax?, checksumAlg?, checksumVal?, idempotencyKey?, responseContentType?, responseContentDisposition?, reason?} ``` PUT constraints (`contentType`, `contentLengthMax`, `checksum*`, `idempotencyKey`, `keyPrefix`), GET response overrides (`responseContentType`, `responseContentDisposition`), and the optional audit `reason` are HMAC-covered and enforced or recorded at dispatch; cross-method fields are rejected. ### Bucket CORS | Method + path | Purpose | | ----------------------------------- | ---------------------------------------- | | `GET /buckets/{bucket}/cors` | get browser CORS rules | | `PUT /buckets/{bucket}/cors` | set browser CORS rules | | `DELETE /buckets/{bucket}/cors` | clear browser CORS rules | | `OPTIONS /signed/{bucket}/{key...}` | signed-URL browser preflight (no bearer) | The native shape is camelCase JSON: ```json { "rules": [ { "id": "browser-direct", "allowedOrigins": ["https://app.example.com"], "allowedMethods": ["GET", "HEAD", "PUT"], "allowedHeaders": ["content-type"], "exposeHeaders": ["ETag", "X-Lockwell-Version-Id"], "maxAgeSeconds": 600 } ] } ``` `PUT /buckets/{bucket}/cors` validates through the same CORS validator and bucket-config store as the S3 `?cors` XML route. It is an admin-scoped bucket operation; ordinary data keys cannot change browser policy. Matching authenticated or signed URL object responses emit `Access-Control-*` headers, but the normal bearer/signed-token, scope, bucket-policy, quota, object-lock, and audit gates still run. ### Bucket event notifications | Method + path | Purpose | | ---------------------------------------- | ------------------------------------------ | | `GET /buckets/{bucket}/notifications` | get the configuration | | `PUT /buckets/{bucket}/notifications` | set it (an empty `configs` list clears it) | | `DELETE /buckets/{bucket}/notifications` | clear it | Only the webhook target is supported. An `sns`/`sqs`/`lambda` target is `501`. A new config ID returns the server-generated `signingSecret` exactly once; it is sealed at rest and omitted from GET and same-ID updates, which carry only `hasSecret`. See [webhooks](/guide/webhooks). ### Native fields surfaced Object reads surface Lockwell-native details S3 XML hides: native checksums (CRC32/CRC32C/CRC64NVME/SHA1/SHA256), encryption and compression status, storage class, version id, retention and legal-hold, and content length. They come back as JSON fields and `X-Lockwell-*` response headers. ## Error shape (`problem+json`) Failures return an `application/problem+json` body: ```json { "code": "not_found", "message": "bucket \"reports\" not found", "status": 404, "requestId": "req_01HXY…" } ``` `code` is a stable machine-readable string. `requestId` correlates the failure with its server-side audit row (also echoed in the `X-Request-Id` header). Status mapping: | Status | Meaning | | ------ | ------------------------------------------------------------- | | `401` | missing/invalid/expired bearer token, or a revoked access key | | `403` | access-key scope or bucket-policy denial | | `404` | bucket/key not found | | `409` | bucket already exists | | `412` | conditional-write or copy-source precondition not met | | `501` | unsupported notification target (SNS/SQS/Lambda) | | `507` | tenant storage quota exceeded | ## Security parity The native API is private by default and never served anonymously. Every control the S3 path enforces is enforced identically here, through the same domain services: * Tenant isolation (from the token, never the path). * Per-operation `read`/`write`/`delete`/`admin` plus bucket/prefix scope enforcement. * Explicit-deny bucket policies. * Encryption, dedup, quota, object-lock, retention, and legal-hold. * Audit on every request, including denials. It does not relax any S3 control or enable any public/anonymous access. See [tenancy and auth](/guide/tenancy-and-auth). ## OpenAPI The full machine-readable contract is an OpenAPI 3 document, available two ways: * Served live at `GET /api/v1/openapi.json` (unauthenticated, since you need the contract before you hold a token). * Committed at [`internal/nativeapi/openapi.json`](https://github.com/RusticStack/lockwell/blob/main/internal/nativeapi/openapi.json) (the canonical source; the served document is the same bytes). Each operation carries a unique `operationId` (`putObject`, `listObjects`, `signURL`, `setBucketNotifications`, …), so you can generate a client in any language with `openapi-generator` (`make codegen` produces TypeScript/Python/Go clients for both the native and admin specs). Prefer the first-party SDKs for Go, Node, and Java; codegen is the path for every other language. If the deployment selects the binary transport, read the separate [Native Wire reference](/reference/native-wire) and [`/native-wire-v1.json`](/native-wire-v1.json). LNW/1 is not an alternate JSON encoding: it has its own 40-byte frame, TLV/document rules, handshake, capability mask, and stable error registry. The HTTP OpenAPI document does not describe that listener. ## Interactive reference Every native operation below is generated from that OpenAPI document, so it always matches the shipped server. Expand an operation for its path and query parameters, request and response schemas, and copy-paste `curl` / `fetch` samples. The example host is a placeholder; swap in your own deployment. --- --- url: /reference/native-wire.md description: >- Machine-oriented LNW/1 frame, capability, operation, field, limit, and error reference. --- # Native Wire reference This page is a compact index for agents and implementers. The normative source is [`docs/native-wire-v1.md`](https://github.com/RusticStack/lockwell/blob/main/docs/native-wire-v1.md); the complete machine contract is [`/native-wire-v1.json`](/native-wire-v1.json). Both are versioned with the repository. LNW/1 is experimental, opt-in, and disabled by default. ## Envelope | Item | Value | | --- | --- | | Magic | ASCII `LKW1` (`4c4b5731`) | | Version | `1` | | Byte order | Unsigned big-endian | | Header | 40 bytes | | Body | metadata TLVs, payload, CRC32C (4 bytes) | | Text | shortest-form UTF-8, NUL-free | | Unknown optional field | Skip | | Unknown critical field | Reject the stream (`UNSUPPORTED_FIELD`) | | Downgrade / code reuse | Forbidden | Header offsets are fixed and must not be inferred from a language ABI: | Offset | Size | Field | | ---: | ---: | --- | | 0 | 4 | magic | | 4 | 2 | version | | 6 | 1 | frame type | | 7 | 1 | flags | | 8 | 8 | connection sequence | | 16 | 8 | request ID | | 24 | 4 | stream ID | | 28 | 2 | operation/error/control code | | 30 | 2 | reserved (zero) | | 32 | 4 | metadata length | | 36 | 4 | payload length | ## Defaults and security | Limit or policy | Default | | --- | --- | | Metadata / DATA payload / frame | 64 KiB / 1 MiB / 1,114,156 bytes | | Streams / connections | 128 / 1,024 | | Stream / connection window | 4 MiB / 16 MiB | | Auth skew | 300 seconds (maximum 5 minutes) | | Replay state | 1,024 principals × 256 nonces per principal | | Upload admission | `security.max_concurrent_uploads = 128`, process-wide | | Upload idle / maximum duration | 2 minutes / 24 hours per stream | | Auth retry hint maximum | 600,000 ms | | Non-loopback TLS | TLS 1.3 + hostname verification | User metadata is an ordered, duplicate-preserving user namespace. Internal SSE-C and Object Lock fields are typed and separate. The effective lockwelld mask excludes `SSE_C` (128); use S3 for genuine SSE-C. Admin bit 16 and operation range `0x1000`–`0x10ff` are reserved and unimplemented. ## Frame types | Code | Type | Role | | ---: | --- | --- | | `0x01` | HELLO | Client version, capabilities, receive limits | | `0x02` | WELCOME | Server selection and intersection | | `0x03` | AUTH | Access-key timestamp, nonce, transcript proof | | `0x04` | AUTH\_OK | Tenant, session, expiry, capabilities, effective limits | | `0x05` | AUTH\_ERROR | Bounded authentication failure | | `0x10` | REQUEST | Starts one operation on an odd client stream | | `0x11` | DATA | Flow-controlled bytes | | `0x12` | END | Closes one direction | | `0x13` | CANCEL | Cancels the stream context | | `0x20` | RESPONSE | Starts a successful response | | `0x21` | ERROR | Typed terminal response | | `0x30` | WINDOW\_UPDATE | Grants stream or connection credit | | `0x31` / `0x32` | PING / PONG | Eight opaque liveness bytes | | `0x33` | GOAWAY | Drain or protocol reason and last accepted stream | | `0x34` | CLOSE | Authenticated close acknowledgement | ## Capabilities | Bit | Name | Advertised by lockwelld | | ---: | --- | --- | | 1 | BUCKETS | yes | | 2 | OBJECTS | yes | | 4 | PAGINATION | yes | | 8 | MULTIPART | yes | | 16 | VERSIONING | yes | | 32 | OBJECT\_LOCK | yes | | 64 | TAGS | yes | | 128 | SSE\_C | **no** (no enforced native path) | | 256 | SIGNED\_CAPABILITY | yes | | 512 | TRACE\_CONTEXT | yes | | 1024 | CORS | yes | | 2048 | NOTIFICATIONS | yes | | 65536 | ADMIN (reserved) | **no** | ## Operations | Code range | Operations | Required capability | | --- | --- | --- | | `0x0001`–`0x0002` | `CAPABILITIES`, `READINESS` | authenticated discovery | | `0x0100`–`0x0105` | bucket CRUD and versioning | BUCKETS | | `0x0200`–`0x0207` | object CRUD, ranges, copy, versions, batch delete | OBJECTS | | `0x0300`–`0x0305` | multipart create/part/list/complete/abort/list | MULTIPART | | `0x0400`–`0x0402` | tags get/put/delete | TAGS | | `0x0500`–`0x0503` | retention and legal hold | OBJECT\_LOCK | | `0x0600` | signed capability mint | SIGNED\_CAPABILITY | | `0x0700`–`0x0702` | bucket CORS get/put/delete | CORS | | `0x0710`–`0x0712` | bucket webhook notifications get/put/delete | NOTIFICATIONS | Clients must not send an operation until its capability is selected. The complete field and document schemas are in the JSON registry; response metadata includes status, ETag, version, length/type, request ID, traceparent, timestamps, delete-marker, retention/legal-hold, and ordered user metadata. ## Errors and retry contract | Code | Name | Typical handling | | ---: | --- | --- | | `0x0103` | AUTH\_REPLAY | terminal; do not retry the proof | | `0x0300` | RATE\_LIMITED | retry only from a fresh connection with fresh credentials/proof when marked retryable | | `0x0301` | UNAVAILABLE | bounded caller retry when operation/body is replay-safe | | `0x0302` | DEADLINE\_EXCEEDED | terminal for the stream; partial upload state is removed | | `0x0303` | CANCELLED | caller cancellation; no implicit replay | | `0x0204` / `0x0205` | RETENTION\_DENIED / LEGAL\_HOLD\_DENIED | authorization/policy denial | | `0x0207` / `0x0208` | CHECKSUM\_MISMATCH / TOO\_LARGE | fix input or limits; do not retry unchanged | `AUTH_ERROR` fields are `retryable` (required bool), `retryAfterMillis` (optional, ≤600000), and bounded `message` (≤512 bytes). Error frames contain safe messages and correlation only; secrets and payloads are never reflected. ## Implementations and source links * [Native-wire architecture and rollout](/guide/native-wire) * [`@kelphect/sdk-native` guide](/sdks/bun-native) (Node 22+, Bun 1.4+; browser denied) * [`@kelphect/sdk-solidstart` guide](/sdks/solidstart) (SolidStart v2; Node/Bun Nitro server presets only) * [`@kelphect/sdk-nextjs` guide](/sdks/nextjs) (Next.js 16.3.3–16.x; Node/Bun-compatible server runtime only) * [Spring Boot starter guide](/sdks/java-spring-wire) (JDK 25, Spring Boot 4.1.1) * [Raw protocol Markdown on GitHub](https://github.com/RusticStack/lockwell/blob/main/docs/native-wire-v1.md) * [Shared fixtures](https://github.com/RusticStack/lockwell/tree/main/tests/native-wire) --- --- url: /reference/admin-api.md description: >- The wire-level reference for the Lockwell JSON Admin API at /admin/api/v1/, covering bearer-token auth, RBAC roles, tenant lifecycle, keys, quotas, and audit. --- # Admin API The JSON Admin API lives at `/admin/api/v1/` on the admin listener (never the public S3 port). It is the control plane: tenants, service accounts, scoped access keys, quotas, usage, and audit. It reuses the exact in-process domain services the HTML admin UI uses (tenant lifecycle, the metadata repo, the SigV4 secret cipher, the auditor), so it cannot bypass any authorization, audit, retention, or quota gate. For most apps, reach this API through a first-party SDK rather than calling it directly: [Go](/sdks/go#pkg-lockwelladmin-the-admin-client), [Node](/sdks/node#adminclient-the-admin-client), or [Java](/sdks/java#lockwelladminclient-the-admin-client). This page is the wire-level reference. ## Authentication Authentication is by an admin API bearer token: a high-entropy secret minted offline with `lockwell admin-token create`, stored only hashed (SHA-256), and distinct from S3 access keys. The wire form is prefixed `lwadm_…`. Send it as: ```text Authorization: Bearer lwadm_… ``` * Token bootstrap is offline-only (`lockwell admin-token create` needs filesystem access, the same trust model as `lockwell admin-create`). There is no JSON route to mint the first token. * Bearer tokens are not sent automatically by browsers, so the API is for server-to-server use and carries no CSRF flow. * Anonymous, unauthenticated, revoked, and expired tokens are denied with `401`. * Every request, success and denial, writes an audit row through the existing auditor. Tokens are rate-limited per token. ## RBAC roles Authorization composes the token's RBAC role with an optional single-tenant scope. A tenant-scoped token cannot cross to another tenant: a cross-tenant target is a `403`, never a `404` existence leak. | Role | Can do | | ---------- | -------------------------------------------------------------------------------- | | `viewer` | read-only: list/get tenants, accounts, keys, quota, usage, audit | | `operator` | the above plus create tenants/accounts/keys, set/clear quota, rotate/revoke keys | | `owner` | the above plus the destructive tenant lifecycle: `disable` and `delete` | ## Routes The server base is `/admin/api/v1`. `GET /healthz` and `GET /openapi.json` are unauthenticated; everything else requires a valid admin token. ### Tenants | Method + path | Role | Purpose | | ---------------------------- | -------- | ------------------------------------------------------- | | `GET /tenants` | viewer | list tenants (global token = all; scoped = its tenant) | | `POST /tenants` | operator | create a tenant | | `GET /tenants/{id}` | viewer | get one tenant | | `POST /tenants/{id}/disable` | owner | disable a tenant; `reason` required | | `POST /tenants/{id}/delete` | owner | delete a disabled tenant; `reason` + `confirm` required | `disable` revokes the tenant's active access keys. `delete` requires the tenant to be disabled first, requires `confirm` to equal the tenant id, and fails closed with a `412` when a retention window or legal hold gates the delete. ### Accounts, keys, quota, usage | Method + path | Role | Purpose | | ---------------------------------------- | ----------------- | ------------------------------------------- | | `GET /tenants/{id}/accounts` | viewer | list service accounts | | `POST /tenants/{id}/accounts` | operator | create a service account | | `GET /tenants/{id}/keys` | viewer | list access keys (secrets never returned) | | `POST /tenants/{id}/keys` | operator | create an access key (secret returned once) | | `POST /tenants/{id}/keys/{keyId}/rotate` | operator | rotate a key (new secret returned once) | | `POST /tenants/{id}/keys/{keyId}/revoke` | operator | revoke a key; `reason` required | | `GET\|PUT\|DELETE /tenants/{id}/quota` | viewer / operator | get / set / clear the tenant quota | | `GET /tenants/{id}/usage` | viewer | storage usage report | The secret on a created or rotated key is shown exactly once and is never recoverable afterward; persist it immediately. `listKeys` returns only metadata. The key request body accepts a `scopes` string (verb list `read,write,delete,admin`, or the resource form `op=read:bucket=reports:prefix=in/,op=write:bucket=reports:prefix=in/`) and an optional `expiresAt` (RFC3339 or `YYYY-MM-DD`). ::: warning A created or rotated key returns its secret exactly once. Capture it from the response immediately; there is no endpoint that reads it back. ::: ### Audit | Method + path | Role | Purpose | | ---------------------------------- | ------ | ------------------- | | `GET /audit?tenant=&since=&limit=` | viewer | query the audit log | `since` is a Go duration string (e.g. `24h`); `limit` is clamped to `[1, 1000]` (default 100). A tenant-scoped token is forced to its own tenant regardless of the `tenant` parameter; an explicit cross-tenant `tenant` from a scoped token is a `403`. ## `reason` / `confirm` and dry runs Destructive operations are gated on the wire: * `reason` is required on `disable`, `delete`, and `revoke` (a `400` otherwise). * `confirm` must equal the tenant id on `delete` (a `400` otherwise). * `?dryRun=true` on any mutation returns the plan the call would execute and applies nothing. For tenant `disable`/`delete` the plan enumerates the buckets, objects, versions, delete markers, legal-held and retained versions, access keys, and physical bytes that would be affected, so you can preview an offboarding before committing it. ```text POST /admin/api/v1/tenants/acme/delete?dryRun=true Authorization: Bearer lwadm_… Content-Type: application/json { "reason": "offboarding", "confirm": "acme" } ``` The SDKs surface this directly: every mutation takes a `dryRun` option (Go/Node) or has a `…DryRun` twin (Java). See [tenancy and auth](/guide/tenancy-and-auth). ## Error shape Failures return an RFC-7807-style JSON problem with a stable `code`, `message`, `status`, and `requestId` (also echoed in `X-Request-Id`), so an operator can correlate a failure with its audit row: ```json { "code": "precondition_failed", "message": "tenant has legal-held object versions", "status": 412, "requestId": "req_01HXY…" } ``` Status mapping: | Status | Meaning | | ------ | ------------------------------------------------------------ | | `400` | validation (missing `reason`, bad `confirm`, malformed body) | | `401` | missing/invalid/revoked/expired admin token | | `403` | RBAC role or cross-tenant scope denial | | `404` | target not found | | `412` | retention / legal-hold gated tenant delete (fail-closed) | | `429` | per-token rate limit exceeded | ## OpenAPI The full machine-readable contract is an OpenAPI 3 document, available two ways: * Served live at `GET /admin/api/v1/openapi.json` (unauthenticated, since an operator needs the contract before holding a token; it leaks no tenant data). * Committed at [`internal/adminapi/openapi.json`](https://github.com/RusticStack/lockwell/blob/main/internal/adminapi/openapi.json) (the canonical source; a human-readable YAML twin lives alongside at `internal/adminapi/openapi.yaml`). Each operation carries a unique `operationId` (`listTenants`, `createTenantKey`, `queryAuditLog`, …), so you can generate a client in any language with `openapi-generator`. Prefer the first-party Go/Node/Java admin clients; codegen is the path for every other language. ## Not exposed here This JSON API mirrors the tenant / account / key / quota / audit subset of the admin surface. Operational controls that live on the HTML admin UI (encryption-key rotation and rewrap, lifecycle, repair/scrub, placement, backup/restore) are not part of the JSON Admin API. There is also no public-access, bucket-policy, or notification configuration surface here. Notifications are configured on the [native data plane](/reference/native-api#bucket-event-notifications). ## Interactive reference Every admin operation below is generated from the OpenAPI document, so it always matches the shipped server. Expand an operation for its parameters, request and response schemas, and copy-paste samples. Send the admin token as `Authorization: Bearer `. The example host is a placeholder; swap in your own admin endpoint. --- --- url: /reference/sdk-capabilities.md description: >- Machine-checkable capability and public-symbol index for Lockwell's Go, Node/TypeScript, Java, Native Wire, and merged server framework integrations. --- # SDK capability index This page is the human-readable companion to [`/sdk-capabilities.json`](/sdk-capabilities.json). Source and executable tests are authoritative; “supported” means a public SDK method exists and the repository exercises its request shape or behavior. Live provider/replacement evidence remains a separate release gate. The shared language-neutral snippets and security notes are available as [`/sdk-public-api-examples-v1.json`](/sdk-public-api-examples-v1.json). ## Shared capability map | Capability | Go | Node/TypeScript | Java | Notes | | -------------------------------- | --------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------- | ----------------------------------------- | | Endpoint prefixes / path style | `New` | `new Client` | `LockwellClient.builder` | Prefix participates in signing/presigning | | Explicit S3 region | `WithRegion` | `region` | `.region` | Default `us-east-1` | | Custom TLS transport / CA | `WithHTTPClient` | `fetch` / `createNodeFetch` | `.httpClient` | Verification stays enabled | | Attempt timeout / cancellation | `WithRequestTimeout`, context | `timeoutMs`, `AbortSignal` | `.requestTimeout`, future cancellation | Defaults differ by surface/language | | Retry policy | `RetryPolicy` | `RetryPolicy` | `RetryPolicy` | Only replay-safe requests | | Progress | `ProgressFunc` | `onProgress` / `TransferProgress` | `ProgressListener` / `TransferProgressListener` | Callback failure cancels | | Response correlation | `WithResponseMetadata` | `onResponseMetadata` | `.responseMetadataListener` | Request ids + optional traceparent | | Bucket CRUD / versioning | all three surfaces | all three surfaces | all three surfaces | Admin is not an object client | | Object CRUD / ranges / streaming | S3 + native | S3 + native | S3 + native + async S3 | Close streaming downloads | | Conditional create | S3 `WithPutIfNoneMatch`, native `IfNoneMatch` | S3/native `ifNoneMatch` | S3 `.ifNoneMatch`, native `.ifAbsent` | Native also supports overwrite-by-ETag | | Checksums | five S3 algorithms + native maps | five S3 algorithms + native maps | five S3 algorithms + native maps | Per-part/composite supported | | Multipart / list / abort | S3 + native | S3 + native | S3 + native + async S3 | Streaming parts supported | | SSE-S3 / SSE-C | typed S3 options | typed S3 options | typed S3 overloads | SSE-C is S3-only; SSE-KMS unsupported | | Tagging / versions | S3 + native | S3 + native | S3 + native + async S3 | Includes delete markers | | Retention / legal hold | get/set/put | S3 reads + native get/set | typed S3 get/set + native get/set | No governance bypass | | S3 presign | GET/PUT/HEAD/DELETE | GET/PUT/HEAD/DELETE | GET/PUT/HEAD/DELETE | Query-SigV4 | | Native signed URLs | GET/PUT constraints | GET/PUT + prefix specialization | GET/PUT constraints | No bearer token on use | | CORS / webhook notification | native | native + kit | native + kit | Webhook only; no SNS/SQS/Lambda | | Health / readiness | native + admin | native + admin | native + admin sync/async | Credential-free probes | | Tenant/key/quota/usage/audit | admin | admin | admin | Dry-run where server supports it | | App kit / ERP helpers | `lockwellkit` helpers | `LockwellKit`, `ErpScopes`, `StorageProfiles`, `ErpErrors` | `LockwellKit`, `ErpScopes`, `StorageProfiles`, `ErpErrors` | Pure helpers do not choose policy | | Edge runtime | not applicable | `/edge` native/admin/kit/helpers | not applicable | S3 client intentionally omitted | The typed SSE-C helpers are implemented on the S3 surface. The merged metadata remediation keeps user metadata lossless and user-owned (including historical reserved names), while internal SSE-C and Object Lock state is typed and separate. Capability presence still does not grant SSE-C to the native wire; the effective LNW server mask excludes it. ## Native Wire transport map LNW/1 is an experimental, opt-in, disabled-by-default transport for the native data plane. It is not a fourth domain surface and does not change the S3 or HTTP-native JSON contracts. | Consumer | Package / entry | Runtime floor | Qualified scope | Explicit absences | | --- | --- | --- | --- | --- | | Shared TypeScript client | `@kelphect/sdk-native@0.1.0` (`/node`, `/bun`, `/protocol`, `/testing`) | Node 22+, Bun 1.4+ | raw TLS 1.3 LNW/1; server-only | browser/default, SSE-C, ADMIN, fallback | | SolidStart adapter | `@kelphect/sdk-solidstart@0.1.0` (`/server`, `/node`, `/bun`) | Node 22+ or Bun 1.4+ server; build toolchain Node 24+ | SolidStart v2; Nitro `node_server`, `node_cluster`, `bun` | edge/static/unknown presets, SSE-C, ADMIN, fallback | | Next.js adapter | `@kelphect/sdk-nextjs@0.1.0` (root `react-server`/`node`) | Next.js 16.3.3–16.x; Node 22+, Bun 1.4+ | App Router server components/actions/Node Route Handlers; standalone/container and representative Node serverless | Client/browser, Edge/middleware, SSE-C, ADMIN, fallback | | Spring starter | `com.lockwell:lockwell-spring-boot-starter:0.2.2` | JDK 25; Spring Boot 4.1.1 tested | lifecycle sync/virtual-thread async, streams, Object Lock, tags, multipart, CORS, notifications, signed capabilities | SSE-C, ADMIN, browser/edge, fallback | The Go standalone LNW client, Node primary-LNW transport, Java shared-core LNW client, and Nuxt adapter remain open source-owner work and are deliberately not listed as supported consumers until their PRs merge and their exact cross-language gates pass. See the [Native Wire guide](/guide/native-wire) and [wire reference](/reference/native-wire). ## Go public namespaces * `pkg/lockwellsdk`: `Client`, `Credentials`, `APIError`, `RetryPolicy`, `Progress`, `ResponseMetadata`, bucket/object/ copy/list/paginator/multipart/tagging/Object Lock/checksum types, all `With*` option functions, and four presign methods. * `pkg/lockwellnative`: `Client`, `NativeError`, health types, object/list/version/tag/retention/legal-hold/multipart/CORS/ notification/signed-URL types and iterators. * `pkg/lockwelladmin`: `Client`, `AdminError`, health, tenant/account/key/quota/usage/audit inputs and results. * `pkg/lockwellkit`: `Kit`, provisioning/ensure/key-scope/signed-URL/webhook helpers and ERP mapping/profile classifiers. The complete method signatures live on the [Go SDK page](/sdks/go) and in package documentation generated from source. Additional exported result, option, iterator, health, progress, and ERP helper symbols are indexed here so a source addition cannot silently outrun the website: ```text BatchDeleteError BatchDeleted CallbackErr ClassifyERPError DataRightsDownloadURL DataRightsExportWrite DeleteError DeletedObject ERPErrorCategory ERPErrorClassification ERPErrorSurface ERPErrorSurfaceAdmin ERPErrorSurfaceNative ERPErrorSurfaceUnknown ERPKeyInput ERPObjectWriteRecipe ERPPurpose ERPPurposeDataRights ERPPurposeExports ERPPurposeFiscalArchive ERPPurposeImports ERPPurposePath ERPPurposePathFor ERPPurposeSupportBundles ERPRetentionSpec ERPRetryDecision ERPRetryDoNotRetry ERPRetryOperatorAction ERPRetryRefreshCredentials ERPRetryWithBackoff ERPScopedClauses ERPSignedDownloadRecipe ERPSignedUploadRecipe ERPTenantMapping EnsureBucket EnsureKey EnsureKeyInput EnsureKeyResult EnsureTenant EnsureTenantProvisioning EnsureTenantProvisioningInput EnsureTenantProvisioningResult ErrorBody ExportDownloadURL FiscalArchiveBucketInput FiscalArchiveWrite GetObjectWithProgress GoString HealthStatus ImportObjectWrite ImportUploadURL LegalHoldOff LegalHoldOn MultipartUploadEntry NewERPTenantMapping ObjectEntry PartListItem ProfileDataRights ProfileExports ProfileFiscalArchive ProfileImports ProfileMetadataKey ProfileSupportBundles ReadinessComponent ReadinessStatus RedactedSupportBundleWrite ResponseMetadataFunc RetentionCompliance SignURLWithResult SignedDownloadURLInput SignedDownloadURLWithResult SignedURL TotalKnown URLForKey VersionEntry WithGetObjectProgress WithGetProgress WithLegalHold WithPartNumberMarker WithUploadIDMarker WithUploadsDelimiter WithUploadsKeyMarker ``` The merged admin/consensus additions are also public Go symbols and are intentionally indexed here: ```text ConsensusChange ConsensusLifecycleInput ConsensusMember ConsensusMemberInput ConsensusReplaceInput DrainConsensusMember ExecuteConsensusRebalance GetConsensusMembership JoinConsensusMember RebalanceInput RebalancePlan RebalanceResult RebalanceSummary RemoveConsensusMember ReplaceConsensusMember VersioningDisabled ``` ## Node/TypeScript exports The default entry exports `Client`, `NativeClient`, `AdminClient`, `LockwellKit`, typed error classes and predicates, `RetryPolicy`, `TimeoutError`, checksum helpers/constants, `buildPresignedGetUrl`, `buildPresignedObjectUrl`, `urlForKey`, `createNodeFetch`, `verifyWebhook`, `WEBHOOK_SIGNATURE_HEADER_NAME`, `ErpScopes`, `StorageProfiles`, `ErpErrors`, their constants, and standalone helper functions. `/edge` exports the native/admin/kit/ERP/retry/WebCrypto subset and omits the S3 client, Node checksum/presign helpers, and Node transport. See [Node SDK](/sdks/node) for method tables and runtime ownership rules. The named constant and standalone-helper exports are: ```text ERP_ERROR_CATEGORIES ERP_ERROR_SURFACES ERP_PURPOSES ERP_RETRY_DECISIONS LEGAL_HOLD_OFF LEGAL_HOLD_ON PROFILE_METADATA_KEY RETENTION_COMPLIANCE STORAGE_PROFILES classifyAdmin classifyNative companyPrefix complianceRetention legalHoldEnabled retentionSpec withLegalHold ``` ## Java public packages * `com.lockwell.sdk`: `LockwellClient`, `LockwellAsyncClient`, `Credentials`, `ApiException`, `RetryPolicy`, `ResponseMetadata`, `Progress`, `TransferProgress`, `Checksums`, `Presign`, `SigV4Signer`, `EndpointPath`, retention/ legal-hold enums, paginator, and health result types. * `com.lockwell.sdk.nativeapi`: `LockwellNativeClient`, `NativeTypes`, `NativeException`, and JSON helpers used by the public native types. * `com.lockwell.sdk.admin`: `LockwellAdminClient`, `AdminTypes`, and `AdminException`. * `com.lockwell.sdk.kit`: `LockwellKit`, `KitTypes`, `ErpScopes`, `StorageProfiles`, and `ErpErrors`. * `com.lockwell.sdk.spring`: `LockwellProperties` and `LockwellAutoConfiguration` for S3 sync/async beans. See [Java SDK](/sdks/java) and [Java native production guide](/sdks/java-native). Public nested/result types that are easy to miss in narrative guides are: ```text CompleteChecksumResult CompleteMultipartResult CompleteResult CreateMultipartResult DeleteError DeleteMarkerEntry DeletedEntry DeletedObject Direction EnsureKeyResult ListEntry ObjectEntry ObjectLockUpdateResult ObjectWriteRecipe PartEntry RetentionMode RetentionResult RetentionSpec SignedDownloadRecipe SignedUploadRecipe SignedUrlMethod TenantMapping UploadPartResult VersionEntry ``` ## `@kelphect/sdk-native` public surface The server-only TypeScript LNW package exports `LockwellNativeClient`, `createLockwellClient`, `Credentials`, `CredentialProvider`, `TLSOptions`, `NativeClientConfig`, `RequestOptions`, `MetricsSink`, `TraceSink`, `LogSink`, `UserMetadataEntry`, `ResponseMetadata`, `ClientDiagnostics`, bucket/object/version/multipart/tag/retention/legal-hold/ CORS/notification/signed-capability request and result types, and the stable `LockwellError` subclasses `ProtocolError`, `TransportError`, `AuthenticationError`, `AuthorizationError`, `ServiceError`, `CancelledError`, `DeadlineExceededError`, `ClientClosedError`, and the redaction helper `redact`. The `/protocol` entry exports `FrameType`, `Operation`, `Capability`, `CLIENT_CAPABILITIES`, `FieldNumber`, `ErrorCode`, `DEFAULT_LIMITS`, `MAGIC`, `VERSION`, `HEADER_BYTES`, and `CRC_BYTES`; `/testing` exports codec fixtures and the injectable socket boundary. See [Native TypeScript](/sdks/bun-native) for runnable usage and package checks. ## `@kelphect/sdk-solidstart` public surface The SolidStart v2 adapter exports the Vite guard (`lockwellSolidStart`, `validateSolidStartRuntime`), runtime refusal error (`UNSUPPORTED_RUNTIME_CODE`, `UnsupportedSolidStartRuntimeError`), server-only lifecycle/configuration helpers, `createLockwellSolidStartClient`, `createNodeLockwellSolidStartClient`, and `createBunLockwellSolidStartClient`, request/response route helpers (`bindLockwellRequest`, `createObjectRouteHandlers`, `getObjectResponse`, `headObjectResponse`, `putObjectResponse`, `uploadPartResponse`), `errorResponse`, `parseSingleRange`, and server-function/action helpers (`requireSolidStartRequest`, `runCurrentLockwellServerFunction`, `runLockwellAction`). Configuration helpers include `resolveLockwellSolidStartServerConfig`, `redactLockwellSolidStartServerConfig`, and `InvalidLockwellSolidStartConfigError` (`INVALID_SOLIDSTART_CONFIG_CODE`); lifecycle state uses `LockwellSolidStartClientLifecycle`, `LockwellSolidStartConfigCollisionError` (`SOLIDSTART_CONFIG_COLLISION_CODE`), and the testing reset helper. Request-event integration exposes `MissingSolidStartRequestEventError` (`NO_SOLIDSTART_REQUEST_CODE`). It supports only Node 22+/Bun 1.4+ server targets and Nitro `node_server`/`node_cluster`/`bun`; browser, edge, static, and unknown targets fail closed. See [SolidStart v2](/sdks/solidstart). ## `@kelphect/sdk-nextjs` public surface The merged Next.js adapter exports `NextLockwellAdapter`, `createNextLockwell`, `getNextLockwell`, `disposeNextLockwell`, `lockwellConfigFromEnv`, `LockwellSecret`, `lockwellDeploymentConfig`, and `lockwellDeploymentDiagnostics`. Route helpers include `putRequest`, `getResponse`, and `headCached`; cache helpers are `lockwellBucketTag`, `lockwellObjectTag`, `expireLockwellObject`, and `revalidateLockwellObject`. Request/error and observability helpers are `lockwellRequestContext`, `safeLockwellError`, `lockwellErrorResponse`, `registerLockwellInstrumentation`, and `lockwellTraceSink`. The adapter re-exports the typed shared native client operations through `adapter.client`. It supports Next.js 16.3.3–16.x on Node 22+ or Bun 1.4+ Node-compatible servers; Client Components, browser bundles, Edge, and middleware fail closed. See [Next.js 16.3](/sdks/nextjs) and the [implementation README](https://github.com/RusticStack/lockwell/blob/main/sdk/nextjs/README.md). Advanced typed/configuration exports are `CacheMutation`, `GetResponseOptions`, `PutRequestOptions`, `LockwellCacheOptions`, `LockwellEnvironment`, `NextLockwellConfig`, `NextLockwellCredentialProvider`, `NextLockwellTLSOptions`, and `SafeLockwellError`. Error and lifecycle classes are `LockwellConfigurationError`, `LockwellRequestError`, and `UnsupportedLockwellRuntimeError`; runtime helpers are `assertLockwellNodeRuntime`, `assertLockwellServerRuntime`, `resolveLifecycle`, `resolveNativeConfig`, and `cachedHeadObject`. The lifecycle type is `LockwellLifecycle` (`"auto"`, `"long-lived"`, or `"serverless"`). ## Spring Boot Native Wire public surface The opt-in starter publishes `com.lockwell.sdk.springwire.LockwellNativeWireClient`, `LockwellNativeWireAsyncClient`, `LockwellNativeWireHealthIndicator`, `LockwellNativeWireProperties`, `NativeWireTypes`, `ResponseMetadata`, `TransferProgressListener`, and `LockwellWireException`. It is JDK 25-first, Spring Boot 4.1.1-tested, and exposes sync/virtual-thread async streaming, Object Lock, checksums, multipart, tags, CORS, webhook, and signed-capability operations. SSE-C and the reserved Admin wire surface are absent. See [Spring Boot Native Wire](/sdks/java-spring-wire). ## Deliberate absences There are no first-party .NET, Rust, PHP, or Ruby SDKs. Those languages are explicit product non-goals; do not use an experimental branch or closed proposal as a production claim. There are no public SDK methods for public buckets, anonymous reads, IAM/STS, SSE-KMS, bucket-policy editing, website hosting, Select, Lambda/Object Lambda, tiering, or arbitrary notification targets. CLI/Web UI-only operator workflows are not silently represented as SDK methods. --- --- url: /reference/content-provenance.md description: >- Source authority, licensing, generated artifacts, canonical URLs, and machine-fetchable formats for Lockwell's public technical documentation. --- # Documentation provenance and licensing Lockwell's public documentation is maintained beside the implementation and executable tests in the [`RusticStack/lockwell`](https://github.com/RusticStack/lockwell) repository. Source and tests are authoritative when a page, generated index, or older deployment disagrees with them. A successful pull-request preview is evidence for that commit only; production may remain on an earlier commit until the change merges and deploys. ## Content license The documentation and examples are covered by the repository's [PolyForm Noncommercial 1.0.0 license](https://github.com/RusticStack/lockwell/blob/main/LICENSE). Commercial use, including TangibleShift integration, requires a written grant from the rights holder. Third-party names, trademarks, linked specifications, and dependencies retain their respective rights. See the repository's [third-party license inventory](https://github.com/RusticStack/lockwell/blob/main/docs/third-party-licenses.md). Code samples on this site explain the corresponding Lockwell SDK or protocol contract. They do not change the license, publish a package, grant production approval, or expand an API beyond the tested source. ## Canonical and fetchable representations Every documentation page has one canonical HTML URL and a same-path `.md` representation. The HTML is statically pre-rendered with its primary heading and article content, so JavaScript is not required to read it. These discovery artifacts are stable entry points: * [`/documentation-index.json`](/documentation-index.json): formats, indexes, source authority, and license metadata. * [`/sdk-capabilities.json`](/sdk-capabilities.json): machine-readable shipping SDK capability index. * [`/llms.txt`](/llms.txt): concise page catalog for agents. * [`/llms-full.txt`](/llms-full.txt): combined Markdown corpus. * [`/sitemap.xml`](/sitemap.xml): canonical crawl inventory. * [`/robots.txt`](/robots.txt): crawler policy and discovery links. Generated native/admin OpenAPI and protocol indexes are linked from the documentation index. Their source commit must match the site preview or deployment being evaluated. ## Generation and verification The VitePress build copies committed machine-readable contracts, generates HTML, sitemap, raw Markdown, and LLM corpora, then validates links, canonical URLs, descriptions, semantic headings, deterministic unique anchors, JSON-LD, and discovery coverage. A separate fetch smoke sends browser, command-line, search-crawler, and AI-crawler user agents to the same routes and requires equivalent public content; no crawler receives a privileged or weakened-security path. ## Reporting drift Report a documentation mismatch with the page URL, source commit, exported symbol or operation, and the executable test that disagrees. Never include access keys, bearer tokens, customer encryption keys, signed URL query strings, object payloads, or private tenant metadata in an issue. --- --- url: /benchmarks.md description: >- The full benchmark ledger as an explorable table. Lockwell vs MinIO with our open harness and with MinIO's own tool (warp), every operation, size, and concurrency, throughput and latency views, the caveats that matter, and the commands that reproduce every row. --- # Benchmarks The promoted public matrix is **`20260810T105308Z-f3fd294`**. It ran on the project's Oracle Ampere A1 ARM64 host and contains 51 rows each for Lockwell, MinIO, Garage, and SeaweedFS, zero request errors, and five completed repair/scrub/backup/restore drills. Its generated competitive gate has **20 failures**, so the promoted evidence does not support a performance-leadership or provider-replacement claim. The immutable manifest, hashes, raw rows, profile, and gate are in `benchmark-baselines/phase1/`. The latest complete unchanged-policy diagnostic is `bench-results/20260815T-full-access-log-23194cf`, bound to merged `23194cf2dcbc93bad904d1743d82c365f9b4fe4d`; it reduced the generated gate to **10 failed checks out of 142** with the same four targets and five drills, but it is not committed or promoted. Until a reviewed run is promoted, the tracked baseline above remains the public release ledger and B-001 remains active. The explorer below is the historical Lockwell-vs-MinIO presentation dataset. It remains useful for navigating operation, size, concurrency, throughput, and latency dimensions, but it is not the current four-target release ledger. Read the [caveats](#caveats) before quoting anything; they are part of the result. ::: tip How to read latency Throughput (MiB/s, ops/s) is "how much per second": **higher is better**. The p50 and p95 views are **response times in milliseconds**: p50 is the median request, p95 the slow tail, and **lower is better**. A Lockwell p50 at half of MinIO's means Lockwell answers twice as fast. The Advantage column already does this arithmetic for you, in the right direction, on every view. ::: ## The ledger ## Caveats These are part of the result, not footnotes to hide. * **Durability tier.** The bench configuration runs Lockwell in its grouped-durability tier (the write-ahead log is fsynced every 10 ms, not per commit, matching Garage's model; a power loss can cost up to ~10 ms of acknowledged writes). MinIO runs its defaults, which sync per operation. Lockwell's default tier is strict per-commit sync; if your threat model requires it, benchmark that tier instead. This asymmetry flatters Lockwell most on small-object PUT, which is exactly where the warp gap is largest. * **Current host.** The promoted four-target baseline ran server and client containers on one Oracle Ampere A1 ARM64 host (four cores, about 24 GiB RAM). Absolute results are host- and image-specific; the complete profile ships so the matrix can be rerun rather than generalized to unrelated hardware. * **MinIO version.** Each run pulls `minio/minio:latest` at run time. Version-to-version variance is real, so cross-run comparisons of old tables mix that in. * **CPU.** Lockwell sustains the higher throughput while using more CPU than MinIO at peak. It trades compute for throughput and disk; if you are CPU-bound, weigh that. * **Failed rows remain visible.** The promoted baseline records 20 local-leadership failures across GET, HEAD, LIST, mixed-RW, multipart PUT, and PUT; the latest complete diagnostic records 10. `BLOCKERS.md` lists the exact tuples, and the generated gate remains authoritative for each artifact. Neither result clears B-001. * **Storage.** Core throughput runs disable compression, deduplication, and encryption for an apples-to-apples engine comparison. Feature-profile storage-efficiency results are separate and must not be substituted for the core matrix. ## Neutral hardware Dev-machine numbers carry dev-machine noise. The standing plan is to run the same two harnesses on a fresh low-cost cloud box (the EUR 5 Hetzner class Lockwell is designed to fit), where nothing else is running, the exact specs are public, and anyone can rent the identical machine and check. [`scripts/bench-remote-hetzner.sh`](https://github.com/RusticStack/lockwell/blob/main/scripts/bench-remote-hetzner.sh) provisions the server with `hcloud`, runs `make bench` and `make bench-warp`, copies the evidence back, and destroys the box; the dataset selector above grows a new entry whenever such a run lands. ## Reproduce ```sh make bench # the full matrix harness (Lockwell, MinIO, Garage, SeaweedFS) make bench-warp # MinIO's warp against Lockwell and MinIO on the same stack ``` Both write raw per-run evidence under ignored `bench-results/`. Promote a completed matrix with `go run scripts/promote-benchmark-baseline.go ...`; only its validated allowlist is committed under `benchmark-baselines/phase1/`. The historical explorer is regenerated with `node website/scripts/build-bench-data.mjs `. The methodology and regression thresholds live in [docs/benchmark-baselines.md](https://github.com/RusticStack/lockwell/blob/main/docs/benchmark-baselines.md). --- --- url: /es.md description: >- Almacenamiento de objetos privado, multi-tenant y cifrado, compatible con S3, con SDKs oficiales para Go, Node y Java. Cada afirmación es una medición, y el repositorio incluye las herramientas para repetirla. --- Lockwell es almacenamiento de objetos privado, multi-tenant y cifrado, self-hosted en un solo binario. Expone tres APIs sobre el mismo almacén: una API compatible con S3 (SigV4, XML), una API JSON nativa en `/api/v1/` y una API de administración JSON en `/admin/api/v1/` en un listener privado. Los SDKs oficiales para Go, Node y Java cubren las tres, y un app kit se encarga del aprovisionamiento de tenants, claves con alcance, subidas firmadas desde el navegador y verificación de webhooks. Lockwell está disponible como software source-available y self-hosted. El servicio gestionado es solo una lista de espera cualificada: todavía no ofrece almacenamiento alojado, pagos, asignación ni SLA. Medición histórica (2026-06-11, una máquina, Docker Compose; reproducir con `make bench` y `make prod-authority-test`): * Recuperación tras crash en 16 segundos con todas las escrituras confirmadas intactas. * 0,54x el disco que usa MinIO para los mismos bytes escritos. * 2,2x el rendimiento de PUT multiparte de MinIO a 64 MiB con 64 clientes concurrentes. * 0 fallos en 24 clientes SDK de S3 en la suite de compatibilidad de producción. Estas filas fechadas no son una afirmación actual de sustitución de proveedor. La prueba competitiva generada más reciente aún tiene fallos; consulta el [registro de benchmarks](/benchmarks) antes de decidir una migración. Rechazado por diseño: buckets públicos, lecturas anónimas, SSE-KMS sin un KMS, y fallbacks silenciosos en llamadas S3 no soportadas. Todo lo que queda fuera de la superficie documentada falla cerrado. Compartir de forma segura se hace con enlaces firmados que caducan. La lista completa, con alternativas para cada rechazo, está en [Cuándo no usar Lockwell](/guide/when-not-to-use). Empieza en [Getting started](/guide/getting-started), elige un SDK en [SDKs](/sdks/) o consulta los [benchmarks](/benchmarks). La documentación técnica está, por ahora, en inglés. --- --- url: /pt.md description: >- Armazenamento de objetos privado, multi-tenant e cifrado, compatível com S3, com SDKs oficiais para Go, Node e Java. Cada afirmação é uma medição, e o repositório traz as ferramentas para a repetir. --- O Lockwell é armazenamento de objetos privado, multi-tenant e cifrado, self-hosted num único binário. Expõe três APIs sobre o mesmo armazenamento: uma API compatível com S3 (SigV4, XML), uma API JSON nativa em `/api/v1/` e uma API de administração JSON em `/admin/api/v1/` num listener privado. Os SDKs oficiais para Go, Node e Java cobrem as três, e um app kit trata do provisionamento de tenants, chaves com âmbito, uploads assinados no browser e verificação de webhooks. O Lockwell está disponível como software source-available e self-hosted. O serviço gerido é apenas uma lista de espera qualificada: não oferece ainda armazenamento alojado, pagamentos, alocação ou SLA. Medição histórica (2026-06-11, uma máquina, Docker Compose; reproduzir com `make bench` e `make prod-authority-test`): * Recuperação após crash em 16 segundos com todas as escritas confirmadas intactas. * 0,54x o disco que o MinIO usa para os mesmos bytes escritos. * 2,2x o débito de PUT multipart do MinIO a 64 MiB com 64 clientes concorrentes. * 0 falhas em 24 clientes SDK S3 na suite de compatibilidade de produção. Estas linhas datadas não constituem uma afirmação atual de substituição de fornecedor. O gate competitivo gerado mais recente ainda tem falhas; consulte o [ledger de benchmarks](/benchmarks) antes de decidir uma migração. Recusado por desenho: buckets públicos, leituras anónimas, SSE-KMS sem um KMS, e fallbacks silenciosos em chamadas S3 não suportadas. Tudo fora da superfície documentada falha fechado. A partilha segura faz-se com links assinados que expiram. A lista completa, com alternativas para cada recusa, está em [Quando não usar o Lockwell](/guide/when-not-to-use). Comece em [Getting started](/guide/getting-started), escolha um SDK em [SDKs](/sdks/), ou consulte os [benchmarks](/benchmarks). A documentação técnica está, por agora, em inglês. --- --- url: /de.md description: >- Privater, multi-tenant, verschlüsselter, S3-kompatibler Objektspeicher mit offiziellen SDKs für Go, Node und Java. Jede Aussage ist eine Messung, und das Repository liefert die Werkzeuge, um sie zu wiederholen. --- Lockwell ist privater, multi-tenant, verschlüsselter Objektspeicher, self-hosted in einem einzigen Binary. Es stellt drei APIs über demselben Speicher bereit: eine S3-kompatible API (SigV4, XML), eine native JSON-API unter `/api/v1/` und eine JSON-Admin-API unter `/admin/api/v1/` an einem privaten Listener. Offizielle SDKs für Go, Node und Java decken alle drei ab, und ein App-Kit übernimmt Tenant-Provisionierung, Schlüssel mit Geltungsbereich, signierte Browser-Uploads und Webhook-Verifikation. Lockwell ist als source-available, selbst gehostete Software verfügbar. Der verwaltete Dienst ist nur eine qualifizierte Warteliste: gehosteter Speicher, Zahlung, Zuteilung und SLA werden noch nicht angeboten. Historische Messung (2026-06-11, ein Host, Docker Compose; reproduzieren mit `make bench` und `make prod-authority-test`): * Crash-Übernahme in 16 Sekunden, alle bestätigten Schreibvorgänge intakt. * 0,54x des Plattenplatzes, den MinIO für dieselben geschriebenen Bytes braucht. * 2,2x MinIOs Multipart-PUT-Durchsatz bei 64 MiB mit 64 gleichzeitigen Clients. * 0 Fehler über 24 S3-SDK-Clients in der Produktions-Kompatibilitätssuite. Diese datierten Zeilen sind keine aktuelle Anbieter-Ersatzbehauptung. Das neueste generierte Wettbewerbs-Gate enthält weiterhin Fehler; vor einer Migrationsentscheidung das aktuelle [Benchmark-Ledger](/benchmarks) prüfen. Verweigert by design: öffentliche Buckets, anonyme Lesezugriffe, SSE-KMS ohne KMS und stilles Durchwinken nicht unterstützter S3-Aufrufe. Alles außerhalb der dokumentierten Oberfläche schlägt geschlossen fehl. Sicheres Teilen funktioniert über signierte Links mit Ablaufzeit. Die vollständige Liste, mit Alternativen je Verweigerung, steht in [Wann Lockwell nicht passt](/guide/when-not-to-use). Start bei [Getting started](/guide/getting-started), SDK-Wahl unter [SDKs](/sdks/), oder die [Benchmarks](/benchmarks) ansehen. Die technische Dokumentation ist vorerst auf Englisch.