Harden the server
TnsServer starts with settings for one person on one machine: it listens on loopback, asks for no token, accepts WebSocket connections only from loopback origins and indexes only its working directory. This page covers each protection and what to change before other people can reach the port.
Defaults
| Protection | Default | Builder method | Flag or environment variable |
|---|---|---|---|
| Bind address | 127.0.0.1 | bindPolicy | --host, --allow-public, TNSAI_HOST, TNSAI_ALLOW_PUBLIC |
| Bearer token | none | authConfig | TNSAI_TOKEN |
| WebSocket origins | loopback hosts on any port | originPolicy | TNSAI_ALLOWED_ORIGINS |
| Session capabilities | always on | none | none |
| Workspace for indexing | working directory, 50,000 files | workspaceConfig | TNSAI_WORKSPACE_ROOT, TNSAI_WORKSPACE_MAX_FILES |
| Request deduplication | in-memory store | idempotencyStore | none |
The flags and variables apply to the tnsai-server launcher; see Server. In code, set the same things on the builder.
Expose the server
static TnsServer publicServer(Supplier<LLMClient> llm, Path projects, IdempotencyStore idempotency) {
return TnsServer.builder()
.bindPolicy(new BindPolicy("0.0.0.0", true)) // allowPublic opts in to a non-loopback host
.authConfig(AuthConfig.withToken(System.getenv(AuthConfig.TOKEN_ENV)))
.originPolicy(OriginPolicy.strict("https://app.example.com"))
.workspaceConfig(WorkspaceConfig.roots(projects).withMaxFileCount(20_000))
.idempotencyStore(idempotency) // shared replay state across instances
.llmClientSupplier(llm)
.build(); // throws on a public host without a token
}Two checks run when the server is built:
- A host other than
127.0.0.1,::1,0:0:0:0:0:0:0:1orlocalhostneedsallowPublicset totrue. Otherwisebuild()throwsIllegalStateException. - A host other than loopback also needs a token. Without one,
build()throwsIllegalStateException.
The server speaks plain HTTP and WebSocket. For TLS, put a reverse proxy in front of it.
Require a token
With a token set, every HTTP request and every WebSocket upgrade must carry Authorization: Bearer <token>. A missing or wrong token gets 401 with the code UNAUTHENTICATED. The comparison takes the same time whatever the token's content.
AuthConfig.withTokenthrowsIllegalArgumentExceptionfor a blank token.AuthConfig.disabled()is the default.- Only
/health,/health/liveand/health/readyare open without a token. Point probes at those paths. The aliases/healthzand/readyzneed the token like any other route. - A browser cannot set headers on a WebSocket. A token-protected server therefore needs a non-browser client, or a proxy that adds the header.
Allow browser origins
Browsers send an Origin header with every WebSocket handshake. The server checks it at the upgrade and answers a rejected origin with 403 and the code FORBIDDEN_ORIGIN. HTTP routes do not check the origin.
| Policy | Accepts |
|---|---|
OriginPolicy.loopback() | localhost, 127.0.0.1, [::1] and ::1 on any port |
OriginPolicy.with(origins...) | The loopback hosts plus the listed origins |
OriginPolicy.strict(origins...) | Only the listed origins |
OriginPolicy.fromEnv() | The loopback hosts plus the comma-separated list in TNSAI_ALLOWED_ORIGINS |
Every policy accepts a handshake with no Origin, a blank one or the value null, so non-browser clients pass. Listed origins match exactly, ignoring case, for example https://app.example.com.
Session capabilities
A session belongs to the first client that touches it. On that first request the server mints a random token and returns it in the X-Session-Capability response header. Every later request for the same session must present it, or it gets 403 with the code FORBIDDEN_SESSION.
| Where | How the token is sent |
|---|---|
POST /api/index, GET /api/index/status, POST /api/search, DELETE /api/index, GET /api/audit | X-Session-Capability header |
WebSocket upgrade, /chat?sessionId=<id>&capability=<token> | capability query parameter or the header |
- Tokens live in memory. A restart clears them, along with the sessions.
- On a WebSocket upgrade, the capability is checked when the URL carries a
sessionId. - Keep the bearer token on whenever other people can reach the port.
Limit the workspace
POST /api/index indexes a folder the client names. Before indexing, the server:
- Resolves the path, following symlinks. For a path that does not exist yet, it resolves the parent folder.
- Checks that the result is inside one of the workspace roots.
- Counts the files under it and stops at the limit.
A path outside every root, or a folder with more files than the limit, gets 400 with the code OUTSIDE_WORKSPACE.
WorkspaceConfig.cwd() is the default. WorkspaceConfig.roots(paths...) sets the roots and withMaxFileCount(n) sets the limit. WorkspaceConfig.fromEnv() reads TNSAI_WORKSPACE_ROOT, a list separated by the platform's path separator (: or ;), and TNSAI_WORKSPACE_MAX_FILES; an invalid number is ignored.
Deduplicate retried requests
A client that sends Idempotency-Key on a POST, PUT or PATCH gets each response once, however often it retries. Requests without the header are not affected.
| Case | Response |
|---|---|
| First request with a key | Runs normally. A 2xx response is stored for 24 hours. |
| Same key, same body | The stored response |
| Same key, different body | 409, code IDEMPOTENCY_KEY_REUSED |
| Same key while the first request still runs | Waits up to 30 seconds for its response, then 409, code IDEMPOTENCY_KEY_IN_FLIGHT |
| Blank key, or longer than 256 characters | 400, code IDEMPOTENCY_KEY_INVALID |
Keys are scoped to the bearer token, the method and the path, so two clients with different tokens cannot see each other's responses. The store keeps a hash of the request body, never the body itself. A response other than 2xx is not stored, so a retry runs the request again.
The default store lives in the process. Behind a load balancer, pass a shared store such as RedisIdempotencyStore or PostgresIdempotencyStore; see Idempotency.
Before you expose the server
- Set a long random
TNSAI_TOKENand keep it out of source control. - Bind the host you need with
--allow-public. - Put a TLS proxy in front of the port.
- List the browser origins that may connect, or use
OriginPolicy.strictfor no loopback origins. - Narrow the workspace roots to the folders clients may index.
- Point health probes at
/health/liveand/health/ready. - Use a shared idempotency store if you run more than one instance.