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 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).
  5. 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/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
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. 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.

ts
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
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
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=<bucket>,op=write:bucket=<bucket>,op=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.

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/<opaque-company-ref>/<purpose>/, 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 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. 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.

ts
await kit.configureBucketCors("acme", "uploads", {
  rules: [
    {
      allowedOrigins: ["https://app.example.com"],
      allowedMethods: ["GET", "HEAD", "PUT"],
      allowedHeaders: ["content-type"],
      exposeHeaders: ["ETag"],
      maxAgeSeconds: 600,
    },
  ],
});
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
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 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.

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