Skip to content

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 client (control plane) and the native data-plane client, into the four jobs an app would otherwise hand-roll:

  1. provisionTenant ensures a tenant exists and mints a scoped data key for it.
  2. clientForTenant is a cached, per-tenant native client that auto-manages its bearer token.
  3. signedUploadUrl / signedDownloadUrl mint browser-usable signed URLs (see signed URLs).
  4. verifyWebhook does constant-time HMAC verification of incoming deliveries (see 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.

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.

ts
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
import (
    "github.com/lockwell/lockwell/pkg/lockwelladmin"
    "github.com/lockwell/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
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.

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.

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.

ts
const { tenantId, created, key, bucket } = await kit.provisionTenant("acme", {
  name: "Acme Inc",
  defaultBucket: "uploads",
});

// Persist key.accessKeyId + key.secretKey now. The secret is shown ONCE.
await saveCreds(tenantId, { accessKeyId: key.accessKeyId, secretKey: key.secretKey });
go
res, err := kit.ProvisionTenant(ctx, "acme", lockwellkit.ProvisionTenantInput{
    Name:          "Acme Inc",
    DefaultBucket: "uploads",
})
if err != nil {
    return err
}
// Persist res.Creds now. res.Creds.SecretKey is shown ONCE.
saveCreds(res.TenantID, res.Creds)
java
import com.lockwell.sdk.kit.LockwellKit.ProvisionOptions;

var p = kit.provisionTenant("acme",
    new ProvisionOptions().tenantName("Acme Inc").defaultBucket("uploads"));

// 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). The scope becomes op=read,write,delete:bucket=<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.

2. Get a per-tenant client

clientForTenant returns a native data-plane client 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.

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

ts
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
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
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. 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 for the full flow.

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

4. Verify an incoming webhook

verifyWebhook reproduces the server's signing exactly (lowercase-hex HMAC-SHA256 over the raw body) and compares in constant time. Honest note: the per-config signing secret is server-side and never returned, so this verifies against a secret your app holds out of band. See webhooks for the full receiver example and the secret story.

ts
const ok = await kit.verifyWebhook(rawBody, signatureHeader, secret);
go
ok := lockwellkit.VerifyWebhook(rawBody, signatureHeader, secret)
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).

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):

ts
const adminClient = kit.admin; // the underlying AdminClient
go
adminClient := kit.Admin() // the underlying *lockwelladmin.Client
java
var adminClient = kit.admin(); // the underlying LockwellAdminClient

Next steps

Released under the Apache-2.0 License. License