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:
provisionTenantensures a tenant exists and mints a fresh scoped data key for it.clientForTenantis a cached, per-tenant native client that auto-manages its bearer token.configureBucketCORSopens the browser policy needed for direct signed URL fetches without storing an admin key.signedUploadUrl/signedDownloadUrlmint browser-usable signed URLs (see signed URLs).verifyWebhookdoes 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.
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" },
});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)
}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.
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 });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)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.
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.
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. :::
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");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"),
})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.
await kit.configureBucketCors("acme", "uploads", {
rules: [
{
allowedOrigins: ["https://app.example.com"],
allowedMethods: ["GET", "HEAD", "PUT"],
allowedHeaders: ["content-type"],
exposeHeaders: ["ETag"],
maxAgeSeconds: 600,
},
],
});_, 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,
}},
})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.
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.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)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.
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:fiscalArchiveBucketcreates the bucket with versioning and Object Lock enabled;fiscalArchiveWriteadds checksum verification, idempotency,If-None-Match: *, profile metadata, and aCOMPLIANCEretention spec supplied with the ERP's retain-until date.imports:importObjectWriteandimportUploadUrlenforce checksum + idempotency; the browser URL is TTL-bounded, prefix-scoped, content-length capped, and still subject to bucket CORS.exports:exportDownloadUrlsets response content headers and a signed audit reason on the native signed URL.data-rights:dataRightsExportWrite/dataRightsDownloadUrlkeep GDPR artifacts explicit; the ERP owns expiry and conflicts between erasure requests and fiscal-retention obligations.support-bundles:redactedSupportBundleWritemarks 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.
const ok = await kit.verifyWebhook(rawBody, signatureHeader, secret);ok := lockwellkit.VerifyWebhook(rawBody, signatureHeader, secret)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.
// 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):
const adminClient = kit.admin; // the underlying AdminClientadminClient := kit.Admin() // the underlying *lockwelladmin.Clientvar adminClient = kit.admin(); // the underlying LockwellAdminClientNext steps
- Data operations. The full native object API the per-tenant client exposes.
- Signed URLs. Browser-direct upload/download in depth.
- Edge runtimes. The kit runs on Workers, Vercel Edge, Bun, and Deno.