The repository ships everything the service needs to run — a Dockerfile, a
Compose file, config/tenants.example.json, and scripts/hash-secret.mjs —
and no runbook, because docs/ is not published. This page is that runbook.
Running your own instance makes you the operator. Nobody issues you
credentials; you issue them. That is the part Get started
assumes is already done, and it is three things: a server HMAC key, a
tenants.json holding at least one tenant, and — only if you want key export —
an X25519 keypair.
Hand it to an agent
Copy this into an agent that has a shell on the server, or SSH access to it: Claude Code, Codex, Cursor, or anything else that can run commands and read back what they printed. It asks you for the handful of values it cannot know, does the work, and stops rather than guessing.
Agent prompt
Deploy TEE Docker to a Linux server
Preflights the host, provisions a tenant, wires the domain, boots the container, and proves it is actually up — not merely running.
You are deploying tee-docker, a self-custody wallet API, onto a Linux server I
control. Work on that server, directly or over SSH. The reference runbook is
https://tee.hypetrade.xyz/deploy/index.md — fetch it if you can; this prompt is
self-contained if you cannot.
Before you touch anything: read the INPUTS, ask me for every one that is blank,
then echo back a plan and wait for me to say go.
## INPUTS
REPO = https://github.com/HypeTradeXYZ/tee-docker
MODE = bare-docker | dokploy # is Dokploy running on this host?
API_HOSTNAME = # e.g. tee-api.example.com; DNS must already point here
HOST_DIR = /opt/tee-docker # absolute host path for everything persistent
TENANT_ID = # lowercase slug, e.g. acme
EXPORT = yes | no # enable sealed key export for this tenant?
## RULES - not suggestions
1. Never invent a value above. Ask me.
2. You generate the secrets. Print each one to me EXACTLY ONCE, in a clearly
marked block, and never again - not in a file, not in a later summary, not
in your final report. Say plainly that the service stores only a hash and
can never give a secret back.
3. Never pass a secret as a command-line argument. Pipe it on stdin. An argv
secret is in `ps` output and in shell history permanently.
4. Exactly one instance may hold a state directory, and the service never
reclaims that lock automatically. Do not raise replicas above 1, do not
point a second stack at the same HOST_DIR, and never delete
state.json.lock while any container might still be running.
5. If a step fails, stop and show me the real log output. Do not set
TEE_ALLOW_UNSAFE_KDF, do not widen file permissions to make an error go
away, and do not remove the health check.
6. Do not go looking for docs/DEPLOY.md. It is not in the repository.
## STEPS
1. PREFLIGHT - report, fix nothing yet:
uname -m (expect x86_64 or aarch64)
nproc; free -m (below ~2 GB RAM, warn me the build may be OOM-killed)
docker --version; docker compose version
getent hosts $API_HOSTNAME (must resolve to this server's public IP)
MODE=dokploy: docker network inspect dokploy-network
2. Clone REPO into a build directory and cd into it.
3. PERSISTENT DIRECTORY. The container runs as uid 1000, and on first boot it
chmods its own state directory to 0700 - which requires ownership. A
root-owned bind mount fails boot with EPERM instead of anything readable:
sudo mkdir -p $HOST_DIR/state $HOST_DIR/data
sudo chown -R 1000:1000 $HOST_DIR
sudo chmod 700 $HOST_DIR/state $HOST_DIR/data
4. SERVER HMAC KEY. Every tenant secretHash is computed under it; changing it
later invalidates all of them at once.
node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
Print it to me once. Tell me to store it in a password manager now, because
a restored data/ directory is unusable without it. Keep it in a shell
variable for step 6 only.
5. TENANT CREDENTIALS. Generate:
apiKey - a public identifier, at least 16 characters, e.g. ak_live_<16 hex>
apiSecret - 32 random bytes, base64url
Print both to me once.
6. SECRET HASH - stdin only, never argv:
printf %s "$API_SECRET" | TEE_SECRET_HMAC_KEY="$HMAC" node scripts/hash-secret.mjs
The output is exactly 64 hex characters. hash-secret.mjs uses only
node:crypto, so it needs no install step.
7. EXPORT KEYPAIR - only if EXPORT=yes:
node -e "const{generateKeyPairSync}=require('node:crypto');const k=generateKeyPairSync('x25519');console.log('x25519:'+k.publicKey.export({format:'der',type:'spki'}).subarray(-32).toString('base64'));console.log(k.privateKey.export({format:'pem',type:'pkcs8'}))"
The first line is exportPublicKey for tenants.json. The PEM private key is
the only thing that can ever open a sealed export - print it to me once and
do not write it anywhere on this server.
If EXPORT=no, OMIT the exportPublicKey field entirely. Leaving the
placeholder from tenants.example.json in place fails schema validation and
the service will not boot.
8. TENANTS.JSON. Build it from config/tenants.example.json: drop the _comment
array, replace every placeholder, keep limits (maxWorkspaces, maxWallets).
Omit the rpc block unless I gave you real, public RPC URLs - every rpc URL
is resolved over live DNS at boot, and one that is unresolvable or on a
private address fails the service closed with
"tenant <id> has an unsafe RPC endpoint". allowDefaultRpc defaults to true.
Install it, then delete your working copy:
sudo install -o 1000 -g 1000 -m 600 tenants.json $HOST_DIR/tenants.json
It must EXIST before first start: Docker creates a DIRECTORY at a missing
single-file mount target, and the service then fails on operator config it
cannot read.
9. SERVE IT.
MODE=dokploy - create a COMPOSE service, not an Application: an Application
is a Swarm service whose rolling update starts the replacement before
stopping the old one, meets the held state lock, and crash-loops on every
deploy. In the Environment tab set TEE_API_HOST=$API_HOSTNAME,
TEE_HOST_DIR and TEE_SECRET_HMAC_KEY. TEE_API_HOST is the one that matters
here: it DEFAULTS to the upstream's own hostname rather than refusing to
start, so leaving it unset comes up healthy while Traefik routes a host we
do not control. Check tls.certresolver=letsencrypt matches the resolver
this Dokploy install actually uses. Deploy from the dashboard.
MODE=bare-docker - there is no Traefik and no dokploy-network. Write
docker-compose.self.yml (the deploy page has the full file) that keeps the
build, volumes, healthcheck and resource limits, drops the labels and the
external network, and publishes 127.0.0.1:3000:3000 - loopback, not
0.0.0.0. Put TEE_HOST_DIR and TEE_SECRET_HMAC_KEY in a chmod 600 .env
beside it. Then:
docker compose -f docker-compose.self.yml up -d --build
Terminate TLS in a reverse proxy in front of it. Tenant secrets and
workspace tokens travel these requests; do not serve them over plain HTTP.
10. VERIFY - running is not up:
curl -fsS http://127.0.0.1:3000/v1/health # bare-docker, on the host
curl -fsS https://$API_HOSTNAME/v1/health # through the proxy
Expect {"status":"ok"}. In the logs expect
native KDF backend verified (probe NNNms)
If instead you see "KDF readiness check failed (probe_slow); refusing
startup", this host is too slow or too throttled for the 1000ms default:
raise TEE_KDF_MAX_PROBE_MS to 2500 and restart. If it says backend_unsafe,
the image is not the shipped glibc one - do not work around it, tell me.
11. PROVE THE CREDENTIALS AGREE with the HMAC key - the only check that
actually tests tenants.json:
curl -X POST "https://$API_HOSTNAME/v1/workspaces" \
-H "X-Api-Key: $API_KEY" -H "X-Api-Secret: $API_SECRET" \
-H "content-type: application/json" \
-d '{"slug":"smoke","password":"<a strong password you print to me once>"}'
A 201 is success. A 401 bad_api_key means secretHash was computed under a
different key than the container is running with.
12. REPORT: the health response, the KDF probe line, the smoke result, every
file you created with its owner and mode, and a checklist of every secret
you printed so I can confirm I stored each one. Confirm you deleted the
build directory's copy of tenants.json.Fill these in before you paste it
| Input | What it is | If you get it wrong |
|---|---|---|
MODE |
dokploy if Dokploy manages this host, otherwise bare-docker |
The wrong networking. The shipped Compose file publishes no host port and expects Traefik on dokploy-network |
API_HOSTNAME |
The hostname you will call, with DNS already pointing at this server | Certificate issuance fails, and the domain 404s at the proxy |
HOST_DIR |
One absolute path holding state/, data/ and tenants.json |
Everything persistent lands inside the container and dies with it |
TENANT_ID |
A lowercase slug for your first tenant, up to 63 characters | Boot fails on schema validation |
EXPORT |
Whether this tenant may export sealed key material | no is reversible later; the field’s absence is the off switch |
What the deployment actually is
One container, one persistent directory, one configuration file you write by hand.
| Piece | Where it lives | Why it is separate |
|---|---|---|
tenants.json |
$HOST_DIR/tenants.json, mounted read-only |
Holds every tenant’s secret hash. Never baked into an image |
state/ |
$HOST_DIR/state, 0700, uid 1000 |
Machine-owned ledger. Never hand-edited |
data/ |
$HOST_DIR/data, 0700, uid 1000 |
Workspace and wallet material |
TEE_SECRET_HMAC_KEY |
Environment only | The key every secretHash is computed under. A data/ backup is unusable without it |
errors.json |
Baked into the image | Ships with the code, so an upgrade cannot leave a stale copy behind |
Stand it up by hand
Prepare the persistent directory
The container runs as the unprivileged node user, uid 1000. On first boot
the service creates its state directory and chmods it to 0700 — and chmod
requires ownership, so a root-owned bind mount fails boot with EPERM rather
than anything that explains itself.
sudo mkdir -p /opt/tee-docker/state /opt/tee-docker/data
sudo chown -R 1000:1000 /opt/tee-docker
sudo chmod 700 /opt/tee-docker/state /opt/tee-docker/dataA named volume does not need this — the image creates those directories as
node, so the volume inherits the right owner. Only bind mounts, which keep
the host directory’s own ownership, need the chown.
Generate the server HMAC key
node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"Store it before you use it. Every tenant’s secretHash is computed under this
key, so replacing it invalidates all of them at once, and a restored data/
directory without it is inert. Anything under 16 bytes is refused at boot;
there is no default, because a per-process random key would break every
configured hash and a fixed fallback would be a published credential.
Hash your first tenant’s secret
The API key is a public identifier — at least 16 characters. The API secret is yours to generate; the service only ever stores its HMAC.
API_SECRET=$(node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))")
printf %s "$API_SECRET" | TEE_SECRET_HMAC_KEY=the-key-from-the-previous-step node scripts/hash-secret.mjsPipe it on stdin. Passed as an argument it lands in ps output and in your
shell history, and it cannot be un-leaked afterwards. hash-secret.mjs uses
only node:crypto, so it runs on a checkout with nothing installed.
The output is exactly 64 hex characters — that is secretHash.
Generate an export keypair, or skip it
Only if this tenant should be able to export sealed key material.
exportPublicKey is not an on/off flag — it is the X25519 recipient every
export for that tenant is encrypted to. A tenant without one cannot export
because there is nowhere to send the secret, not because a policy says no.
Whoever will hold the exported keys generates this pair, and keeps the private half. If you are your own first tenant, that is you, right here. If the tenant is another developer, they generate it and send you the public half only — see What to collect from a developer. The private half must never reach the server; it is the only thing that can open a sealed blob, and that is exactly what makes a stolen token useless.
node -e "const{generateKeyPairSync}=require('node:crypto');\
const k=generateKeyPairSync('x25519');\
console.log('x25519:'+k.publicKey.export({format:'der',type:'spki'}).subarray(-32).toString('base64'));\
console.log(k.privateKey.export({format:'pem',type:'pkcs8'}))"The first line is the value for exportPublicKey, in the only form the schema
accepts: x25519: followed by base64 of the raw 32-byte key.
Write tenants.json
Build it from config/tenants.example.json, dropping the _comment array.
{
"tenants": [
{
"id": "acme",
"apiKey": "ak_live_0123456789abcdef",
"secretHash": "the 64 hex characters from the previous step",
"limits": { "maxWorkspaces": 5, "maxWallets": 200, "maxUnlockedWorkspaces": 8 },
"allowDefaultRpc": true
}
]
}If a browser will call this instance, the tenant needs an origins array too —
see Letting a browser call it.
Install it before the first start, owned by uid 1000 and readable by nobody else:
sudo install -o 1000 -g 1000 -m 600 tenants.json /opt/tee-docker/tenants.jsonEvery rpc URL you put in this file is resolved over live DNS during
startup, and one that is unresolvable or points at a private address fails
the service closed. Leave the block out unless you have real public endpoints —
allowDefaultRpc defaults to true. See
Networks and RPC.
Serve it
The shipped docker-compose.yml publishes no host port and expects Traefik on
an external dokploy-network. On a plain host, keep it untouched — so git pull stays clean — and write a sibling file instead.
# docker-compose.self.yml — plain Docker host, own reverse proxy in front.
services:
tee-docker:
build:
context: .
dockerfile: Dockerfile
image: tee-docker:local
init: true
restart: unless-stopped
# Loopback only. Terminate TLS in a proxy in front of this; tenant
# secrets and workspace tokens travel these requests.
ports:
- "127.0.0.1:3000:3000"
environment:
TEE_SECRET_HMAC_KEY: "${TEE_SECRET_HMAC_KEY:?set it in .env beside this file}"
TEE_KDF_MAX_PROBE_MS: "${TEE_KDF_MAX_PROBE_MS:-1000}"
volumes:
- "${TEE_HOST_DIR:?set TEE_HOST_DIR to an absolute host path}/state:/var/lib/tee-docker/state"
- "${TEE_HOST_DIR:?set TEE_HOST_DIR to an absolute host path}/data:/var/lib/tee-docker/data"
- "${TEE_HOST_DIR:?set TEE_HOST_DIR to an absolute host path}/tenants.json:/app/config/tenants.json:ro"
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/v1/health').then(r=>r.json()).then(j=>process.exit(j.status==='ok'?0:1)).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
start_period: 60s
retries: 3
deploy:
resources:
limits:
cpus: "1.0"
memory: "1024M"Put the two variables in a .env beside it and lock it down — it holds the
HMAC key in plaintext. .dockerignore already keeps .env* out of the build
context, so it cannot end up inside the image.
printf 'TEE_HOST_DIR=/opt/tee-docker\nTEE_SECRET_HMAC_KEY=%s\n' "$HMAC" > .env
chmod 600 .env
docker compose -f docker-compose.self.yml up -d --buildThen point your proxy at 127.0.0.1:3000. Caddy is two lines:
tee-api.example.com {
reverse_proxy 127.0.0.1:3000
}Create a Compose service, not an Application. An Application runs as a Swarm service, and a Swarm rolling update starts the replacement task before stopping the old one — it meets the held state lock and crash-loops on every single deploy until someone deletes the lock file by hand. Compose recreates a changed service stop-then-start, which hands the lock over cleanly.
The same reasoning pins deploy.replicas to 1. Do not raise it.
Point the service at your fork or clone. The Traefik router is declared in the Compose file’s labels and reads its hostname from the environment, so there is nothing to edit in the file itself.
In the Environment tab set:
| Variable | Value |
|---|---|
TEE_API_HOST |
tee-api.example.com |
TEE_HOST_DIR |
/opt/tee-docker |
TEE_SECRET_HMAC_KEY |
the key you generated |
Check that traefik.http.routers.tee-docker.tls.certresolver=letsencrypt
matches the resolver name your Dokploy install actually uses — a mismatch
presents the same way, as a Traefik 404 rather than a certificate error.
Everything else in the file has a working default. Nothing goes in the Domains tab — the labels already declare the router.
Prove it is up, not merely running
curl -fsS https://tee-api.example.com/v1/health{ "status": "ok" }Then prove tenants.json and the HMAC key agree, which nothing else tests:
curl -X POST "https://tee-api.example.com/v1/workspaces" \
-H "X-Api-Key: $API_KEY" \
-H "X-Api-Secret: $API_SECRET" \
-H "content-type: application/json" \
-d '{"slug":"smoke","password":"a-strong-workspace-password"}'A 401 bad_api_key here means the secretHash was
computed under a different key than the container is running with. From here,
Get started picks up at minting a token.
Environments that need a second look
A small or shared VPS
Password derivation is Argon2, which is deliberately memory-hard. Startup runs
one real derivation before the port opens, and refuses to continue if it
takes longer than TEE_KDF_MAX_PROBE_MS.
That budget defaults to 1000. The same probe measures 559 ms on an idle
Apple-silicon laptop — under two times headroom on fast hardware, which means
a throttled shared core can and does exceed it. Raising it to 2500 on such a
host is reasonable:
environment:
TEE_KDF_MAX_PROBE_MS: "2500"The ceiling is 60000, and the check itself is not optional. TEE_ALLOW_UNSAFE_KDF=1
exists as an emergency operator acknowledgement — it still runs and reports the
probe, and it logs UNSAFE KDF ACCEPTED BY OPERATOR. Reaching for it because
boot failed is choosing to run password derivation you have not verified.
Memory matters twice over. At runtime, an OOM-kill strands the state lock and
costs you a manual recovery, so keep the memory limit at 1024M or above. At
build time, the builder stage installs a toolchain and compiles TypeScript; on
a 1 GB instance that is the step most likely to be killed without a useful
message. Build somewhere larger and push the image, or size the box up.
arm64 and x86_64
node:22-bookworm-slim — Debian, glibc — is a deliberate choice, not a default.
Password derivation goes through @node-rs/argon2, whose linux-x64-gnu and
linux-arm64-gnu prebuilds load on glibc with no C toolchain present. Startup
refuses to continue unless that native backend is the one actually selected, so
a base image that silently falls back to a slower implementation is a boot
failure, not a slow build. It presents as:
KDF readiness check failed (backend_unsafe); refusing startupBoth architectures are fine. What is not fine is a mismatch: building the image
on an Apple-silicon laptop and pushing it to an x86_64 server ships
linux-arm64-gnu binaries that cannot load there. Both Compose files here build
on the host, which sidesteps it entirely — if you build elsewhere, build for the
target platform explicitly.
Swapping the base to node:22-alpine fails the same way. musl is not glibc, and
the prebuilds do not load.
Dokploy
Covered in the tab above, and worth restating because each one costs a deploy cycle to discover:
- Compose, never Application. Swarm’s rolling update overlaps the old and new tasks, and two instances against one state directory is exactly what the lock refuses.
replicas: 1is a correctness constraint, not a capacity decision.- The certresolver name in the shipped labels is
letsencrypt. If your install names it something else, the domain 404s. - Nothing goes in the Domains tab. The router is declared in the labels.
Bare Docker behind your own proxy
The shipped Compose file deliberately publishes no host port — Traefik reaches
the container over dokploy-network. Without that network, nothing reaches it
at all. The docker-compose.self.yml above publishes 127.0.0.1:3000 instead:
bound to loopback, so only a proxy on the same host can reach it. Publishing
0.0.0.0:3000 puts a wallet API on the public internet with no TLS in front of
it.
If you are on Podman rather than Docker, the uid-1000 ownership step works
differently under rootless mode — the container’s uid 1000 maps to a different
host uid through your subuid range. Either use a named volume, or work out the
mapped uid before you chown.
When it will not come up
Every one of these is a hard boot failure by design: the service refuses to start half-understood rather than run in a state you did not intend.
| What you see | What it is | What to do |
|---|---|---|
TEE_SECRET_HMAC_KEY is not set. |
No key in the environment | Set it. There is no default, deliberately |
TEE_SECRET_HMAC_KEY is too short |
Key under 16 bytes | Generate a 32-byte one |
cannot read operator config at /app/config/tenants.json |
The host file was missing at first start, so Docker created a directory there | sudo rm -rf $HOST_DIR/tenants.json, write the real file, restart |
EPERM / EACCES during boot |
Bind mount not owned by uid 1000 | sudo chown -R 1000:1000 $HOST_DIR |
KDF readiness check failed (probe_slow); refusing startup |
The derivation exceeded its budget on a slow or throttled host | Raise TEE_KDF_MAX_PROBE_MS, or give the container more CPU |
KDF readiness check failed (backend_unsafe); refusing startup |
The native Argon2 backend is not the one selected — usually a swapped base image or an architecture mismatch | Use the shipped node:22-bookworm-slim build, built for the target architecture |
KDF readiness check failed (probe_failed); refusing startup |
The probe threw rather than finishing. It derives a throwaway workspace under the container’s temp directory, so this is usually no writable temp space — or the native module failing to load at all | Check the temp directory and its free space; if that is fine, treat it as the backend_unsafe case |
tenant <id> has an unsafe RPC endpoint |
An rpc URL is a placeholder, unresolvable, or on a private address |
Fix it, or drop the rpc block and rely on allowDefaultRpc |
expected a canonical X25519 public key |
exportPublicKey is still the example placeholder |
Generate a real one, or remove the field |
exportPublicKey is not a usable X25519 recipient |
Well formed, but not a valid curve point | Regenerate it |
service state is already locked. |
Another instance holds it — or one died without releasing it | See below. Do not delete anything yet |
| Traefik 404 at the domain | Certresolver name mismatch, or DNS not resolving | Check the label against your Traefik config, and the A record |
401 bad_api_key on a correct-looking call |
secretHash computed under a different HMAC key |
Recompute every tenant hash under the running key |
Reading the logs
The service logs to stdout, so the container runtime holds them. There is no log file to find.
docker compose -f docker-compose.self.yml logs -f tee-dockerOn Dokploy, the same stream is the service’s Logs tab.
Output is colourised even when it is not a terminal, which makes saved logs
noisy. Set NO_COLOR=1 in the environment for plain text before you pipe logs
anywhere.
A healthy boot says so explicitly:
[Nest] 1 - 08/20/2026, 5:25:20 PM LOG [KdfCheckService] native KDF backend verified (probe 559ms)
[Nest] 1 - 08/20/2026, 5:25:20 PM LOG [bootstrap] tee-docker listening on :3000Failed key exports
Export is the highest-consequence route in the service, so it is the one
operation where when did this key leave has to be answerable afterwards.
Every attempt is logged — successes and failures alike — as a structured
record under the event name key_export.
An export that fails after passing the scope check logs outcome: 'FAILURE'
at WARN, from ExportController:
[Nest] 1 - 08/20/2026, 5:25:20 PM WARN [ExportController] Object(8) {
event: 'key_export',
outcome: 'FAILURE',
tenantId: 'acme',
workspaceSlug: 'demo',
accountSlug: 'treasury-a1b2',
target: 'privateKey',
walletId: 1,
vm: 'evm'
}An export refused because the token lacked the export scope never reaches
the controller. It is logged separately by the scopes guard as
outcome: 'DENIED', and it is the record that carries a requestId you can
match against the client’s error body:
[Nest] 1 - 08/20/2026, 5:25:20 PM WARN [ScopesGuard] Object(7) {
event: 'key_export',
outcome: 'DENIED',
tenantId: 'acme',
workspaceSlug: 'demo',
target: 'privateKey',
required: [ 'export' ],
requestId: '01J...'
}Both are pretty-printed across several lines, so a bare search finds the header and drops the fields. Ask for trailing context:
docker logs tee-docker 2>&1 | rg -A 10 key_exportReading the outcomes:
| Sequence | What happened |
|---|---|
ATTEMPT then SUCCESS |
Sealed material was returned. Note the time |
ATTEMPT then FAILURE |
The caller had the scope; the operation failed — export not enabled for the tenant, wrong account kind, unknown wallet, or a VM the wallet has no address for |
DENIED alone, no ATTEMPT |
The token lacked export. Nothing was decrypted, nothing was read |
ATTEMPT with no terminal record |
The process died mid-operation. Treat it as unresolved |
The record deliberately never contains key material, a mnemonic, or the sealed blob — only who asked, for what, and how it ended.
Redeploys and backups
An ordinary redeploy is safe. Compose stops the running container before starting its replacement, so the lock is released and retaken in order. There is a brief gap with no service: this deployment cannot do zero-downtime releases, by design, because the alternative is two writers on one workspace.
What is never safe is anything that leaves two containers against one state
directory — raising replicas, a second service pointed at the same
TEE_HOST_DIR, or a manual docker compose up alongside a Dokploy-managed
stack.
Back up the whole of $HOST_DIR with the service stopped. data/ holds
wallet material and state/ holds the ledger describing it; a backup that
catches the two at different moments may not restore cleanly. Exclude
state.json.lock — it is process state, not data — or delete it from the
restored copy before starting.
The HMAC key belongs in your secret store, not in the filesystem backup. A
restored data/ directory is unusable without it, and no part of the backup
contains it.
What to collect from a developer
Once the service is up, onboarding someone onto it is two values in one tenant
entry in tenants.json. Both are edited by hand, both take effect only on
restart, and neither has an endpoint behind it.
| Ask them for | Lands in | Without it |
|---|---|---|
Every origin their code runs on, e.g. https://app.example.com and http://localhost:4321 |
origins |
Their browser calls fail while curl works |
Their X25519 public key, x25519: followed by 43 base64 characters and = |
exportPublicKey |
They cannot even mint a token with the export scope |
Collect both at the same time. They are the only two things about a tenant that a developer supplies rather than you deciding, and each one strands them in a way that looks like a bug in your deployment rather than missing configuration.
Letting a browser call it
Everything above assumes a server-side caller — curl, or your own backend.
A browser is different. It will happily send the request and receive the
response, then refuse to let your script read it unless the origin the script
runs on has been allowlisted on a tenant.
That allowlist is a per-tenant origins array in tenants.json:
{
"tenants": [
{
"id": "acme",
"apiKey": "ak_live_0123456789abcdef",
"secretHash": "the 64 hex characters from earlier",
"limits": { "maxWorkspaces": 5, "maxWallets": 200 },
"origins": ["https://app.example.com", "http://localhost:4321"]
}
]
}CORS is not an access-control boundary
Worth being blunt about, because readers get this wrong in both directions.
An allowlisted origin grants nothing. Every route still requires
X-Api-Key and X-Api-Secret, or a bearer token. CORS decides only whether a
browser will let script read a response it was already authorized to receive.
So: handing over your domain does not give anyone access, and being on the allowlist does not let a page call the API without credentials. The two questions are unrelated.
What each entry must look like
Exactly scheme://host with an optional port, http or https, and nothing
else — because that is all a browser ever sends as an Origin.
https://app.example.com ✓
http://localhost:4321 ✓
https://sub.domain.example.com:8443 ✓Everything below is rejected at boot — the service refuses to start rather than carry an entry that could never match:
| Rejected | Why |
|---|---|
* |
Not an origin. There is no wildcard mode |
https://*.example.com |
No wildcard subdomains either — list each one |
https://app.example.com/ |
A trailing slash. A browser never sends one |
https://app.example.com/path |
Origins have no path |
https://user:[email protected] |
Origins carry no credentials |
app.example.com |
No scheme |
ftp://app.example.com |
http and https only |
"" |
Empty string |
Failing closed is the point: an entry a browser can never match would otherwise sit in the allowlist looking effective.
What the allowlist actually permits
Leave origins off every tenant — the default, and the previous behaviour —
and CORS is not enabled at all.
Set it on any tenant and the allowlist is the union across all tenants. It has to be: a preflight arrives before authentication, so there is no tenant to scope it to yet. That union decides readability only, which is why it grants nothing.
| Value | |
|---|---|
| Methods advertised | GET, POST, PUT, DELETE — plus OPTIONS, which is the preflight itself |
| Request headers accepted | authorization, content-type, x-api-key, x-api-secret, x-request-id |
| Response headers readable from script | x-request-id, retry-after, x-rpc-source. Nothing else |
| Credentials | Off. Access-Control-Allow-Credentials is never sent — auth is header-based and nothing reads a cookie, so a browser client must set its auth headers explicitly |
| Preflight cache | 600 seconds |
The published route surface is unchanged at 32 routes. Preflight is handled as
middleware, not as a route, so no OPTIONS entries appear in the reference.
When it does not work
The symptom is “curl works, the browser does not.” That one sentence explains almost every report you will get. The server is not rejecting the caller — the browser is refusing to hand the response to script.
Every failure looks the same from outside, which is unusually opaque:
- A preflight from an origin no tenant registered returns
404— not204, not403. The refusal is not short-circuited, so it falls through to the unroutedOPTIONS. The browser console shows only the generic No ‘Access-Control-Allow-Origin’ header is present on the requested resource. - A simple request returns
200with its real body, and is merely unreadable to script.GET /v1/healthis the only route simple enough to skip preflight, so it is the one place you can watch this happen. - CORS switched off entirely and your origin merely missing are
indistinguishable — both
404, with nothing in the response saying which. Telling them apart means readingtenants.json.
Do not expect the status code to tell you which of those it is. It cannot.
Nothing is logged operator-side when an origin is refused, and the Origin
value is not recorded anywhere — so there is no log line to go and find. The
diagnosis is manual, and it is short: ask the developer for their exact origin
string, and compare it against the tenant’s array by eye, scheme and port
included. https://app.example.com and https://app.example.com:443 are
different strings to the matcher, even though a browser treats them as the same
page.
Registering an export key
The other half of onboarding, and the one that strands a developer earlier than they expect.
{
"id": "acme",
"apiKey": "ak_live_0123456789abcdef",
"secretHash": "the 64 hex characters from earlier",
"limits": { "maxWorkspaces": 5, "maxWallets": 200 },
"origins": ["https://app.example.com"],
"exportPublicKey": "x25519:Sbw+3ZFGa1P6EiLjasvKZg4HBeh8Vx86v8JukHa3PGE="
}The value is canonical base64 of a 32-byte X25519 public key, prefixed
x25519:. Validation at boot is real, not a format check: the service performs
an actual X25519 agreement against the key and rejects a degenerate result, so
an invented or all-zero value will not pass. It also rejects a non-canonical
base64 spelling of an otherwise valid key.
The failure comes earlier than the export call
A tenant with no exportPublicKey is refused at token mint, not at the
export route:
curl -X POST "$API_URL/auth/token" \
-H "X-Api-Key: $API_KEY" -H "X-Api-Secret: $API_SECRET" \
-H "content-type: application/json" \
-d '{"workspace":"demo","password":"…","scopes":["read","sign","export"]}'{
"error": {
"code": "export_disabled",
"message": "no exportPublicKey registered for this tenant",
"status": 403
}
}So the report you get is “I can’t even get a token with the export scope”,
not “export returned an error” — and a developer reading their own code will
reasonably suspect their scope request rather than your configuration.
The export routes refuse the same way, with the same
export_disabled code and the same message. That
is deliberate — a token can never carry a scope the tenant cannot use — but it
does mean the code alone does not tell you which of the two calls produced it.
Check whether the tenant has an exportPublicKey before reading anything else
into it.
Next
- Get started — mint a token against the instance you just deployed
- Safe usage — handling the credentials you now issue
- Scopes and permissions — including why
exportis gated three separate ways - Errors — every code the service returns