Skip to content

Go SDK

The first-party Lockwell SDK for Go, split into four packages. Each is a separate import, so you pull in only the surface you need.

The non-test source imports only the standard library (plus a small internal pkg/errors). A consumer gets a light dependency tree and never reaches into server internals.

sh
go get github.com/lockwell/lockwell
go
import (
    "github.com/lockwell/lockwell/pkg/lockwellsdk"    // S3 (SigV4) data plane
    "github.com/lockwell/lockwell/pkg/lockwellnative" // native JSON data plane
    "github.com/lockwell/lockwell/pkg/lockwelladmin"  // JSON admin API
    "github.com/lockwell/lockwell/pkg/lockwellkit"    // the app kit
)

The Go module path is github.com/lockwell/lockwell; the repository lives at github.com/KelpHect/lockwell.

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. It signs each request with the exact SHA-256 of the body, never UNSIGNED-PAYLOAD, so writes are replay-safe and eligible for idempotent retry.

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)

Construction

Construct the client with New(endpoint string, creds Credentials, opts ...Option) (*Client, error). The endpoint scheme must be http or https. Buckets are addressed path-style, so a virtual-host endpoint is neither required nor supported.

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

Buckets

MethodSignature
CreateBucketCreateBucket(ctx, bucket string, opts ...BucketOption) error
HeadBucketHeadBucket(ctx, bucket string) error
DeleteBucketDeleteBucket(ctx, bucket string) error
PutBucketVersioningPutBucketVersioning(ctx, bucket string, status VersioningStatus) error
GetBucketVersioningGetBucketVersioning(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

MethodSignature
PutObjectPutObject(ctx, bucket, key string, body []byte, opts ...PutOption) (*PutObjectResult, error)
PutObjectStreamPutObjectStream(ctx, bucket, key string, r io.Reader, size int64, checksum ChecksumAlgorithm, opts ...PutOption) (*PutObjectResult, error)
GetObjectGetObject(ctx, bucket, key string, opts ...GetOption) (*GetObjectOutput, error)
HeadObjectHeadObject(ctx, bucket, key string, opts ...GetOption) (*HeadObjectOutput, error)
DeleteObjectDeleteObject(ctx, bucket, key string, opts ...GetOption) error
DeleteObjectsDeleteObjects(ctx, bucket string, objects []ObjectIdentifier, opts ...DeleteObjectsOption) (*DeleteObjectsOutput, error)
CopyObjectCopyObject(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

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

PutObjectResult carries ETag, VersionID, Checksums, and ServerSideEncryption.

Conditional writes on the S3 client (If-None-Match, If-Match) are available on the copy-source preconditions below and on the native client. The S3 PutObject itself does not take an If-None-Match option in the Go SDK.

GetObject / HeadObject options

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

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.

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:

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

Listing

MethodSignature
ListObjectsV2ListObjectsV2(ctx, bucket string, opts ...ListOption) (*ListObjectsV2Output, error)
ListObjectsListObjects(ctx, bucket string, opts ...ListOption) (*ListObjectsOutput, error)
ListObjectVersionsListObjectVersions(ctx, bucket string, opts ...ListVersionsOption) (*ListObjectVersionsOutput, error)
ListMultipartUploadsListMultipartUploads(ctx, bucket string, opts ...ListUploadsOption) (*ListMultipartUploadsOutput, error)
ListPartsListParts(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

MethodSignature
CreateMultipartUploadCreateMultipartUpload(ctx, bucket, key string, opts ...PutOption) (*CreateMultipartUploadOutput, error)
UploadPartUploadPart(ctx, bucket, key, uploadID string, partNumber int, body []byte, opts ...UploadPartOption) (*UploadPartOutput, error)
UploadPartCopyUploadPartCopy(ctx, srcBucket, srcKey, srcVersionID, dstBucket, dstKey, uploadID string, partNumber int, byteRange string) (*UploadPartCopyOutput, error)
CompleteMultipartUploadCompleteMultipartUpload(ctx, bucket, key, uploadID string, parts []CompletedPart, opts ...PutOption) (*CompleteMultipartUploadOutput, error)
AbortMultipartUploadAbortMultipartUpload(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.

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 reads

MethodSignature
PutObjectTaggingPutObjectTagging(ctx, bucket, key string, tags map[string]string, opts ...GetOption) error
GetObjectTaggingGetObjectTagging(ctx, bucket, key string, opts ...GetOption) (map[string]string, error)
DeleteObjectTaggingDeleteObjectTagging(ctx, bucket, key string, opts ...GetOption) error
GetObjectRetentionGetObjectRetention(ctx, bucket, key string, opts ...GetOption) (*ObjectRetention, error)
GetObjectLegalHoldGetObjectLegalHold(ctx, bucket, key string, opts ...GetOption) (bool, error)

Retention and legal hold are set on the write through WithObjectLockRetention and WithObjectLockLegalHold (see PutObject options), then read back with these methods. Pass WithVersionID to target a specific version.

Presigned GET

PresignGetObject(bucket, key string, expires time.Duration, opts ...GetOption) (string, error) returns a time-limited unauthenticated GET URL. Lockwell presigns GET only; there is no presigned PUT, HEAD, or DELETE on the S3 client.

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.

Presigned writes on the S3 client are a deliberate non-goal. For a signed upload URL, use the native client's

SignURL. :::

go
url, err := c.PresignGetObject("reports", "q1.txt", 15*time.Minute)

For a 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.

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/lockwellnative (the native client)

Talks to the native JSON data plane at /api/v1/. No SigV4, no XML.

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.New(
    "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

MethodSignature
ListBucketsListBuckets(ctx) ([]Bucket, error)
CreateBucketCreateBucket(ctx, in CreateBucketInput) (*Bucket, error)
GetBucketGetBucket(ctx, bucket string) (*Bucket, error)
DeleteBucketDeleteBucket(ctx, bucket string) error
GetBucketVersioningGetBucketVersioning(ctx, bucket string) (*VersioningState, error)
SetBucketVersioningSetBucketVersioning(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

MethodSignature
PutObjectPutObject(ctx, in PutObjectInput) (*PutObjectResult, error)
GetObjectGetObject(ctx, in GetObjectInput) (*ObjectReader, error)
HeadObjectHeadObject(ctx, in GetObjectInput) (*ObjectInfo, error)
DeleteObjectDeleteObject(ctx, bucket, key, versionID string) (*DeleteObjectResult, error)
ListObjectsListObjects(ctx, in ListObjectsInput) (*ListObjectsResult, error)
ListObjectsAllListObjectsAll(ctx, in ListObjectsInput) *ObjectIterator
BatchDeleteObjectsBatchDeleteObjects(ctx, bucket string, keys []BatchDeleteKey) (*BatchDeleteResult, error)
CopyObjectCopyObject(ctx, in CopyObjectInput) (*CopyObjectResult, error)

PutObjectInput fields:

FieldEffect
Body io.ReaderStreamed with no whole-object buffering.
ContentTypeStored media type (default application/octet-stream).
ContentLength int64When > 0, sets Content-Length so the server enforces the size cap and quota up front; otherwise the body is sent chunked.
IdempotencyKeyThe 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]stringAlgorithm ("sha256", "crc32c", ...) to expected base64 digest. A bad digest is rejected before any bytes are committed.
Metadata map[string]stringUser 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.

MethodSignature
GetObjectTagsGetObjectTags(ctx, bucket, key string) ([]Tag, error)
SetObjectTagsSetObjectTags(ctx, bucket, key string, tags []Tag) ([]Tag, error)
DeleteObjectTagsDeleteObjectTags(ctx, bucket, key string) error
GetObjectRetentionGetObjectRetention(ctx, bucket, key string) (*Retention, error)
SetObjectRetentionSetObjectRetention(ctx, bucket, key, mode, retainUntil string) (*Retention, error)
GetObjectLegalHoldGetObjectLegalHold(ctx, bucket, key string) (*LegalHold, error)
SetObjectLegalHoldSetObjectLegalHold(ctx, bucket, key, status string) (*LegalHold, error)
ListObjectVersionsListObjectVersions(ctx, in ListObjectVersionsInput) (*ListObjectVersionsResult, error)

Unlike the S3 client, where retention and legal hold are set only on write, the native client sets them after the fact. 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

MethodSignature
CreateMultipartUploadCreateMultipartUpload(ctx, bucket, key string) (*MultipartUpload, error)
UploadPartUploadPart(ctx, in UploadPartInput) (*UploadedPart, error)
ListPartsListParts(ctx, bucket, key, uploadID string) (*PartListing, error)
CompleteMultipartUploadCompleteMultipartUpload(ctx, in CompleteMultipartInput) (*CompletedMultipart, error)
AbortMultipartUploadAbortMultipartUpload(ctx, bucket, key, uploadID string) error
ListMultipartUploadsListMultipartUploads(ctx, bucket string) (*MultipartUploadListing, error)

UploadPartInput{Bucket, Key, UploadID, PartNumber, Body, ContentLength} streams the part body. CompleteMultipartInput{Bucket, Key, UploadID, IfNoneMatch, IfMatch} gates the completed object atomically at the commit.

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.

Bucket notifications

Native notifications configure signed webhook delivery. SNS/SQS/Lambda targets are a 501 non-goal.

The per-config signing secret is generated and held server-side and is never returned; the view carries only HasSecret.

go
views, err := nc.SetBucketNotification(ctx, "reports", lockwellnative.SetBucketNotificationInput{
    Configs: []lockwellnative.NotificationConfig{{
        WebhookURL: "https://my-app.example.com/hooks/lockwell",
        Events:     []string{"s3:ObjectCreated:*", "s3:ObjectRemoved:*"},
        Filters:    []lockwellnative.NotificationFilter{{Name: "prefix", Value: "incoming/"}},
    }},
})
fmt.Println(views[0].HasSecret) // true; the secret itself is never returned
MethodSignature
SetBucketNotificationSetBucketNotification(ctx, bucket string, in SetBucketNotificationInput) ([]NotificationView, error)
GetBucketNotificationGetBucketNotification(ctx, bucket string) ([]NotificationView, error)
DeleteBucketNotificationDeleteBucketNotification(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 to intent:

HelperStatus
IsUnauthorized(err)401 (bad/missing/expired token, or a revoked key)
IsForbidden(err)403 (scope or bucket-policy denial)
IsNotFound(err)404 (missing bucket or key)
IsAlreadyExists(err) / IsConflict(err)409 (bucket already exists)
IsPreconditionFailed(err)412 (conditional-write or copy-source precondition)
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

MethodSignature
ListTenantsListTenants(ctx) ([]Tenant, error)
GetTenantGetTenant(ctx, id string) (*Tenant, error)
CreateTenantCreateTenant(ctx, in CreateTenantInput) (*Tenant, *DryRunResult, error)
DisableTenantDisableTenant(ctx, id string, in DisableTenantInput) (*Plan, error)
DeleteTenantDeleteTenant(ctx, id string, in DeleteTenantInput) (*Plan, error)
GetQuotaGetQuota(ctx, tenantID string) (*Quota, error)
SetQuotaSetQuota(ctx, tenantID string, in SetQuotaInput) (*Quota, *DryRunResult, error)
ClearQuotaClearQuota(ctx, tenantID string, dryRun bool) (*Quota, *DryRunResult, error)
GetUsageGetUsage(ctx, tenantID string) (*Usage, error)
ListAccountsListAccounts(ctx, tenantID string) ([]Account, error)
CreateAccountCreateAccount(ctx, tenantID string, in CreateAccountInput) (*Account, *DryRunResult, error)
ListKeysListKeys(ctx, tenantID string) ([]Key, error) (never returns secrets)
CreateKeyCreateKey(ctx, tenantID string, in CreateKeyInput) (*NewKey, *DryRunResult, error)
RotateKeyRotateKey(ctx, tenantID, keyID string, in RotateKeyInput) (*NewKey, *DryRunResult, error)
RevokeKeyRevokeKey(ctx, tenantID, keyID string, in RevokeKeyInput) (*Key, *DryRunResult, error)
QueryAuditQueryAudit(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:

text
op=read,write,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.

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",
})
// 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)

// 4) Verify an incoming webhook (constant-time HMAC-SHA256).
ok := lockwellkit.VerifyWebhook(rawBody, req.Header.Get(lockwellkit.WebhookSignatureHeader), secret)
MethodSignature
NewNew(admin *lockwelladmin.Client, nativeEndpoint string, opts ...Option) (*Kit, error)
ProvisionTenantProvisionTenant(ctx, tenantID string, in ProvisionTenantInput) (*ProvisionResult, error)
ClientForTenantClientForTenant(tenantID string, creds TenantCreds) (*lockwellnative.Client, error)
SignedUploadURLSignedUploadURL(ctx, creds TenantCreds, bucket, key string, in SignedUploadURLInput) (*SignedUpload, error)
SignedDownloadURLSignedDownloadURL(ctx, creds TenantCreds, bucket, key string, ttlSeconds int64) (string, error)
VerifyWebhookVerifyWebhook(rawBody []byte, signatureHeader string, secret []byte) bool
AdminAdmin() *lockwelladmin.Client

ProvisionTenant mints a data key only (default read,write,delete, or op=read,write,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.

Webhook secret honesty: Lockwell generates the per-config webhook signing secret server-side and never returns it (the notification view carries only HasSecret). VerifyWebhook is for when your app already holds the secret out of band, for example a secret you embedded in a webhook URL you control. 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.

Released under the Apache-2.0 License. License