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.
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.
go env -w GOPRIVATE=github.com/KelpHect/*
go get github.com/KelpHect/[email protected]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/lockwellis the retainedv0.xcompatibility module path. The canonical repository isRusticStack/lockwell; GitHub's repository-transfer redirect keeps the established imports resolvable. SetGOPRIVATE=github.com/KelpHect/*and authenticate GitHub beforego 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.
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.
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
PutObjectsupports create-onlyIf-None-Match: *throughWithPutIfNoneMatch("*"). Overwrite-onlyIf-Matchis available on 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.
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.
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).
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.
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.
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.
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. :::
url, err := c.PresignGetObject("reports", "q1.txt", 15*time.Minute)For a native signed write URL, use the native client's SignURL.
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-<alg> 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.
// 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.
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.
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.
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.
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: "<etag>" | 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:
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".
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:
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.
// 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.
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.
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 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.
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.
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:
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.
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.
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.
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=<Bucket>,op=write:bucket=<Bucket>,op=delete:bucket=<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
SigningSecretfrom the new-config response immediately. GET and same-ID updates expose onlyHasSecret; use the stored value withVerifyWebhook. See webhooks.
Coverage at a glance
The full S3 operation matrix shared by all three S3 clients lives on the S3 operations reference. The native and admin wire contracts are documented on the native API and Admin API reference pages.