Auth and identity#
Ticket Board has one identity model and three carriers. The model is a CurrentActor — a frozen dataclass that knows the workspace, the user, and (for bots) the project allowlist and read/write mode. Every request that mutates state must produce one. Routers extract it; services act on it; the audit log records it.
The three carriers — session cookie, X-API-Key header, and MCP path token — funnel into the same actor object. Downstream code never branches on transport.
This page is the operator-facing summary. The exhaustive spec lives in spec/04_auth_and_identity.md. The bot URL decision is ADR-0002; the single-user-table decision is ADR-0003.
Tenets#
- One actor object, three doors. Session cookie,
X-API-Keyheader, and MCP path token all resolve to the sameCurrentActordataclass onrequest.state. Services never branch on transport. - Resolved exactly once per request. Authentication runs at the edge in middleware. Downstream code reads from request state; re-deriving the actor is a bug.
- Authorization lives in services, not routers. Routers extract the actor and call services. The service is the only place that says "this actor may do this thing."
- Audit every auth event. Successful logins, failed logins, token issuance, rotation, revocation, bot enable/disable — all flow into the same audit log as domain events.
- Bot credentials are bearer secrets. Tokens are never logged, never echoed after creation, and never compared with non-constant-time operations.
The actor model#
@dataclass(frozen=True, slots=True)
class CurrentActor:
user_id: UUID
workspace_id: UUID
type: ActorType # HUMAN or BOT
username: str
display_name: str
bot_mode: BotMode | None # None for humans
bot_project_allowlist: tuple[UUID, ...] | None # None for humans
session_id: UUID | None # None for bots
request_id: UUID # echoed back as X-Request-Id
Rules:
type=HUMAN⇒bot_mode is None,bot_project_allowlist is None,session_id is not None.type=BOT⇒bot_mode is not None,bot_project_allowlist is not None(may be empty),session_id is None.- Frozen: services pass it around without worrying about mutation.
- Not an ORM object. The
userrow is the source of truth;CurrentActoris the per-request snapshot.
The dataclass lives in domain/actor.py and depends on nothing — no SQLAlchemy, no FastAPI. The frozenness and the absence of ORM coupling are deliberate: the actor is the cleanest object in the system and must stay that way.
Authentication resolution#
Resolution happens at two sites with an identical output contract: every authenticated request produces one CurrentActor exactly once before any route or tool handler runs.
- REST middleware (
auth/middleware.py::ResolveActorMiddleware). Resolves humans and bots for everything under/api/v1/*. It explicitly short-circuits toNonefor paths starting with/mcp/bot/so the MCP shim below has sole ownership of MCP auth. - MCP ASGI shim (
mcp/context.py::McpTokenAuthApp). Mounted at/mcp/bot; resolves the bot from the URL path segment before the request reaches the REST middleware. The resolved actor is placed on a contextvar (_current_actor_var) that tool handlers read viaget_current_actor().
The split exists because FastMCP's StreamableHTTP transport is mounted as a sub-application; auth must happen before FastMCP dispatches, so it lives in the shim rather than the request middleware.
The REST middleware's resolution order, in precedence:
X-API-Keyheader. If the header is present and non-empty, resolve as a bot. If a session cookie is also present, reject as ambiguous.tb_sessioncookie. If no header and a valid cookie is present, resolve as a human.- None. The actor is unauthenticated. Most endpoints reject; a small allowlist (login, health, diagnostics) permits anonymous access.
The MCP shim's contract is simpler: it parses /mcp/bot/{token}/..., resolves the bot, and rejects the request before FastMCP sees it on any failure (unknown prefix, revoked, hash mismatch).
flowchart TD
REQ[Request] --> P{Path matches<br/>/mcp/bot/?}
P -- yes --> MCP[Resolve bot<br/>from URL]
P -- no --> X{X-API-Key<br/>header?}
X -- yes --> C1{Cookie<br/>also set?}
C1 -- yes --> AMB[401 ambiguous_credentials]
C1 -- no --> XBOT[Resolve bot<br/>from header]
X -- no --> C2{tb_session<br/>cookie?}
C2 -- yes --> HUM[Resolve human<br/>from session]
C2 -- no --> ANON[Actor = None]
Conflict and failure semantics#
| Condition | Outcome | HTTP | Envelope code |
|---|---|---|---|
| MCP path, valid token | bot actor | n/a | n/a |
| MCP path, invalid/revoked token | reject | n/a | auth.invalid_credentials |
REST path, X-API-Key valid |
bot actor | — | — |
REST path, X-API-Key invalid/revoked |
reject | 401 | auth.invalid_credentials |
| REST path, cookie expired or unknown | reject | 401 | auth.invalid_credentials |
REST path, both X-API-Key AND cookie present (any state) |
reject | 401 | auth.ambiguous_credentials |
| REST path, no credential, endpoint requires auth | reject | 401 | auth.unauthenticated |
| REST path, no credential, endpoint is anonymous | proceed (actor=None) | — | — |
The "both credentials present" case is rejected even if both are individually valid. A client cannot accidentally swap identities — an SDK bug that adds a cookie to a bot's request must surface, not silently take the cookie.
Human sessions#
Cookie#
| Attribute | Value |
|---|---|
| Name | tb_session |
| Value | 32 random bytes URL-safe base64 (~43 chars). Mapped server-side. |
| Flags | HttpOnly; Secure (prod); SameSite=Lax; Path=/ |
| Max-Age | absent — session-lifetime cookie; server-side expiry is the truth |
| Domain | omitted (host-only) |
The cookie value is a bearer credential. We persist only its SHA-256 hash in the session table. The cookie has 256 bits of entropy, so no per-session salt is needed.
Why server-side sessions instead of JWT?#
Server-side sessions are the right fit here because the system is single-process, revocation is a real requirement, and the audit log already wants a session id for correlation. JWT's stateless-verify advantage does not earn its keep at this scale:
- One DB lookup per request is trivial against a co-located Postgres.
- Revocation requires either a denylist (defeating statelessness) or a short TTL (defeating UX).
- Server-side sessions are revocable in one DB write, observable in admin views, and don't bloat headers.
Lifecycle#
| Event | Behavior |
|---|---|
| Idle timeout | SESSION_IDLE_TIMEOUT_DAYS (default 7). If now() - last_seen_at > idle, reject with auth.invalid_credentials; emit auth.session.expired(reason='idle'). |
| Absolute timeout | SESSION_ABSOLUTE_TIMEOUT_DAYS (default 30). Past expires_at, no extension is possible; emit auth.session.expired(reason='absolute'). |
| Logout | Sets revoked_at = now(). Cookie is cleared via Set-Cookie: tb_session=; Max-Age=0. |
| Password change | Self-service change (POST /auth/change-password) keeps the current session and revokes the user's other sessions, so the user stays logged in where they made the change while any compromised sessions elsewhere are cut. CLI recovery (reset_password.py) revokes all sessions. |
| Last-seen throttle | Middleware writes last_seen_at only if older than 60 seconds. Idle math uses the in-memory now() minus the stored value; the throttle affects only audit-trail granularity. |
| Sweeper | Hard-deletes rows where expires_at < now(). Idle-expired rows are caught here too once they cross absolute. |
Login flow#
sequenceDiagram
autonumber
participant Browser
participant API as FastAPI /auth/login
participant Svc as auth_service
participant DB as Postgres
Browser->>API: POST /api/v1/auth/login { username, password }
API->>Svc: authenticate_human(username, password, ip, ua)
Svc->>DB: SELECT user WHERE username = ? AND type='human' AND is_active=true
DB-->>Svc: user row
Svc->>Svc: bcrypt.verify(password, user.password_hash)
Svc->>DB: INSERT session (user_id, token_hash, ip, ua, expires_at)
Svc->>DB: emit audit_event(auth.login.success)
Svc-->>API: (user, session_token)
API-->>Browser: 200 { user } + Set-Cookie: tb_session=<token>
Note over Browser,API: Subsequent requests
Browser->>API: GET /api/v1/projects (cookie)
API->>API: middleware: sha256(cookie) → DB lookup → CurrentActor
API-->>Browser: 200 { ... }
Login failure handling#
| Cause | HTTP | Audit event |
|---|---|---|
| Unknown username | 401 | auth.login.failure reason=user_not_found |
| User inactive | 401 | auth.login.failure reason=inactive |
| Wrong password | 401 | auth.login.failure reason=wrong_password |
| Rate-limit hit | 429 | auth.login.rate_limited (bucket: ip or username) |
The 401 response is identical across the first three causes. The audit log carries the precise reason. This avoids username enumeration while keeping the security log useful.
Password requirements#
- Hashing: bcrypt with cost ≥ 12.
- Minimum length: 12 characters. No maximum.
- No composition rules (no required digits, no required symbols). Per NIST SP 800-63B, composition rules push users toward predictable patterns and don't increase entropy at the margin.
- No breached-password check in v1. (
haveibeenpwnedk-anonymity API is the right tool; out of scope for the v1 launch.) - No rotation policy. Forced periodic rotation is anti-NIST and creates churn. We rotate on suspicion, not on schedule.
Self-service password change#
A signed-in human changes their own password from Settings → Security (/settings/security in the web app), which calls POST /auth/change-password. The endpoint is human-only and requires the X-Requested-By: web CSRF header.
- Request:
ChangePasswordRequest { current_password, new_password }. Success:204 No Content. - Validation (all
400):auth.current_password_invalidwhencurrent_passwordis wrong;validation.password_too_shortwhennew_passwordis under the 12-character minimum;validation.password_unchangedwhennew_passwordequals the current password. The same policy as recovery applies — minimum 12 characters, new password must differ from the old one. - Session handling: the current session is preserved and the user's other sessions are revoked (see the lifecycle table). Emits
auth.password.changedwithactor_kind='human'.
This is the routine path for a human rotating their own password. The CLI below is for recovery when the password is lost.
Admin recovery#
If the lone human loses their password, the recovery path is a CLI invocation run on the host:
The Makefile target wraps backend/scripts/reset_password.py, which prompts for a new password, applies the same validation rules as the API, writes a fresh bcrypt hash, and revokes all existing sessions. It emits an auth.password.reset_via_cli audit event with actor_kind='system'. The threat model is "John forgot his password," not "attacker pivots through the CLI" — the script requires shell or DB access to the running container.
Bot tokens#
Format#
tkb_is a 4-character visible prefix. Two purposes: (1) operators can spot a leaked token in logs or repos viagrep -E 'tkb_[a-z2-7]{32}', and (2) the prefix lets incident scanners filter without guessing.- 32 base32 chars carry 160 bits — generated as
secrets.token_bytes(20)and base32-encoded. base32 is case-insensitive and copy-paste-friendly without ambiguous characters. - The first 8 chars after
tkb_are stored asbot_token.token_prefix(lookup hint, not auth). The remainder participates only in the argon2id hash.
Hashing#
argon2id with time_cost=3, memory_cost=64*1024, parallelism=4. argon2id was chosen over bcrypt because:
- Bot tokens are verified far more often per second than human passwords — memory-hardness matters more.
- bcrypt's 72-byte input limit and lack of memory-hardness are real drawbacks for machine-to-machine credentials.
argon2-cffiis mature and drops into aPasswordHasher-style interface cleanly.
Verification#
async def resolve_bot_by_token(token: str) -> CurrentActor:
# Shape only — see auth/tokens.py for the real implementation.
if not token.startswith("tkb_") or len(token) != 4 + 32:
raise InvalidCredentials()
prefix = token[4:12] # 8 chars after "tkb_"
candidates = await bot_token_repo.find_active_by_prefix(prefix) # list, not row
if not candidates:
await argon2_hasher.verify(DUMMY_HASH, token) # constant-time on miss
raise InvalidCredentials()
for row in candidates:
if await argon2_hasher.verify(row.token_hash, token):
if row.revoked_at is not None or not row.bot_user.is_active:
raise InvalidCredentials()
return build_actor(row.bot_user)
raise InvalidCredentials()
The dummy verify on miss closes the obvious timing side channel where "wrong prefix" would return instantly and "wrong tail" would run argon2. The dummy hasher is cached per (time_cost, memory_cost, parallelism) tuple so the timing profile matches a real verify. The prefix lookup is a single indexed query; an 8-char base32 prefix collision is rare but possible, so the implementation iterates candidates rather than asserting a single match. A colliding prefix doesn't bypass the hash check.
Issuance and rotation#
| Action | What changes | Plaintext returned? |
|---|---|---|
POST /bots (create) |
New user row + first bot_token row (is_active=true). |
Yes — once. |
POST /bots/{id}/tokens/rotate |
Active row stamped rotated_at = now(), is_active=false. New active row inserted. |
Yes — once. |
POST /bots/{id}/tokens/revoke |
Active row stamped revoked_at = now(), is_active=false. No new row. |
No. Bot cannot authenticate. |
DELETE /bots/{id} |
user.is_active = false. All bot tokens become unusable. |
No. |
No grace period in v1. Rotation immediately invalidates the previous token. The trade-off:
- Pro: simpler invariant ("exactly one active token per bot"), enforced by partial unique index. No "which of two valid tokens did this request use?" ambiguity in the audit log.
- Con: a bot running with the old token gets auth errors until reconfigured. For a homelab with a handful of bots, the operator reconfigures them manually after rotation.
The shape of a future grace feature is documented but not implemented: add a superseded_at column distinct from rotated_at, and allow the previous row to authenticate until superseded_at + grace_window.
Plaintext handling#
- Plaintext is held in a local variable in the create/rotate handler long enough to build the response, then garbage-collected.
- Never written to any log, any audit payload, or any error trace.
- The
bot.token.createdandbot.token.rotatedaudit events recordtoken_idandtoken_prefixonly. - The plaintext appears in the response body and as
Authorization: Bearer ...-style guidance text in the response'susagefield, so the operator copy-pastes a complete instruction rather than a bare token. The HTTP response is the only carrier.
MCP path token#
The token in /mcp/bot/{token} is the same value as the bot's X-API-Key. The URL itself is a credential. This is a deliberate choice driven by client-compatibility realities; see ADR-0002.
Because URLs are credentials in this design, logging redaction is mandatory at multiple layers:
| Layer | Redaction implementation |
|---|---|
| Caddy access log | filter encoder with a regexp field sub-filter rewriting /mcp/bot/<token>/... → /mcp/bot/<redacted>/.... |
| Backend structlog middleware | A processor in observability/logging.py applies the same regex to any log field that could contain a URL. |
| Tracing exporters | Future: same regex before export. Not yet wired (tracing is v2). |
Reverse proxies in front of the system must apply the same redaction in their access logs. This is an operator-runbook responsibility called out explicitly to ensure it isn't missed.
Authorization#
Authorization decisions live in the service layer, not in routers. Routers extract the actor via dependency injection (current_actor, current_human, current_bot, current_bot_writer) and call services. The service is the only place that says "this actor may do this thing."
Humans#
- v1 has exactly one human user with full read/write authority within their workspace.
- Endpoints flagged "humans-only" use the
current_humandependency to reject bots. - No role/permission table in v1. When multi-human arrives, the model gains role assignment;
CurrentActoralready has room.
Bots#
Two-dimensional scope: project allowlist and read/write mode.
Project allowlist#
An empty allowlist ([]) grants the bot access to all projects in the workspace — it is the global-access sentinel value, not "no access". A non-empty allowlist restricts the bot to exactly those project ids.
| Path | Behavior |
|---|---|
| List/read | Empty allowlist: unfiltered (all projects visible). Non-empty allowlist: service filters project_id IN actor.bot_project_allowlist. The repository base exposes apply_actor_project_scope(query, actor) for systematic, grep-able enforcement. |
| Single fetch | Denied only when the allowlist is non-empty and the resource's project_id is not listed. Else: allowed. Denied case returns NotFound (not Forbidden) — same shape as classical IDOR defense. |
| Write | Denied with Forbidden(reason='project_not_in_allowlist') only when the allowlist is non-empty and the target project is not in it. Empty-allowlist bots may write to any project. |
| Cross-project list | Empty-allowlist bot: sees all projects. Non-empty-allowlist bot: sees only listed projects. No "you have N projects you can't see" hint. |
Read/write mode#
- Read endpoints/tools: callable by any bot that has project access (empty allowlist or project listed).
- Write endpoints/tools: additionally require
bot_mode == 'write'. Mismatch →Forbidden(reason='bot_read_only'). - A write attempt by a read-only bot also emits
auth.bot.write_attempt_in_read_modeso the operator can spot misconfigured bots.
Global wiki#
Global wiki pages (wiki_page.project_id IS NULL) are visible to all bots for reads. Writes are gated by the per-bot can_edit_global_wiki flag, defaulting to BOT_CAN_EDIT_GLOBAL_WIKI_DEFAULT (default false). A bot without the flag attempting a global write receives Forbidden(reason='bot_cannot_edit_global_wiki'). Humans bypass this check entirely.
Audit log access#
The audit log endpoint is humans-only. Bots cannot view audit events under any circumstances — the audit log is the security log, and bots are the subjects of investigation.
Authorization decision flow#
flowchart TD
A[Incoming request] --> B{Authenticated?}
B -- no --> R401[401 auth.unauthenticated]
B -- yes --> C{Actor type?}
C -- human --> D{Endpoint requires bot?}
D -- no --> ALLOW1[Proceed]
D -- yes --> R403H[403 auth.forbidden]
C -- bot --> E{Endpoint requires human?}
E -- yes --> R403B[403 auth.forbidden humans_only]
E -- no --> F{Allowlist non-empty AND project not listed?}
F -- yes --> R403P[403 auth.forbidden project_not_in_allowlist]
F -- no --> G{Write op AND mode=read?}
G -- yes --> R403R[403 auth.read_only_bot]
G -- no --> ALLOW2[Proceed]
CSRF and security headers#
The session-cookie auth needs protection against cross-site requests:
- Cookie attribute:
SameSite=Laxblocks cross-site form posts to state-changing endpoints by default. - Custom header: state-changing JSON endpoints (POST/PATCH/DELETE) require
X-Requested-By: webfrom browser clients. Cross-origin form posts cannot set the header without a preflight, and noCORSMiddlewareis mounted in the FastAPI process — preflights for other origins simply fall off the routing table. - Bot SDKs are exempt — the
X-API-Keyheader itself proves intent and origin. - Single-origin deployment. Caddy fronts both UI and API, so cross-origin requests cannot reach the backend through the proxy. If a different reverse proxy is placed in front of the API, origin-checking must be enforced there.
Edge headers (HSTS, X-Frame-Options, X-Content-Type-Options) are enforced at the reverse proxy. The deployment docs cover the Caddy configuration.
A startup check in lifespan.py asserts Settings.cookie_secure=True when Settings.env == "prod" and refuses to boot otherwise. This makes "ran prod with cookie_secure=false" an impossible mistake.
Rate limiting#
v1 enforces an in-memory sliding-window limiter on the login endpoint only. Per-actor throttling for general REST and MCP traffic is planned, not implemented — the column exists so a future limiter can read it without a migration.
| Bucket | Capacity | Window | Status |
|---|---|---|---|
/auth/login per IP |
5 attempts | 15 min sliding | enforced |
/auth/login per username |
5 attempts | 15 min sliding | enforced |
| Per-actor REST + MCP | user.rate_limit_per_minute (default 60) |
per minute | planned; column reserved |
| Bot token rotation per human | — | — | planned |
Settings.default_rate_limit_per_minute populates the column on user creation. Until the per-actor limiter ships, no middleware reads the value and no X-RateLimit-* headers are emitted.
The single-process design is acknowledged. Horizontal scale would require a shared store (Redis); the migration is sketched but out of v1 scope.
Settings#
The auth surface introduces (or formalizes) these env-driven settings. Defaults match what's in .env.example.
| Setting | Default | Purpose |
|---|---|---|
SESSION_IDLE_TIMEOUT_DAYS |
7 |
Idle window for human sessions. |
SESSION_ABSOLUTE_TIMEOUT_DAYS |
30 |
Absolute lifetime; past this, no extension. |
IDEMPOTENCY_KEY_TTL_HOURS |
24 |
TTL applied to new idempotency records. |
TOOL_INVOCATION_LOG_RETENTION_DAYS |
90 |
How long tool_invocation_log rows live before the sweeper purges them. |
DEFAULT_RATE_LIMIT_PER_MINUTE |
60 |
Default user.rate_limit_per_minute for newly created actors. |
BOT_CAN_EDIT_GLOBAL_WIKI_DEFAULT |
false |
Default can_edit_global_wiki for newly created bots. |
SWEEPER_INTERVAL_MINUTES |
60 |
Shared schedule for the session / idempotency / tool-log sweeper. |
auth.bcrypt_cost |
12 |
Human password hashing cost. |
Settings.cookie_secure |
True in prod |
Asserted at startup when Settings.env == "prod"; refuses to boot otherwise. |
Settings.docs_enabled |
False in prod |
Controls Swagger/Redoc exposure. |
For the broader settings audit (and which env variables are redacted in startup logs), see the Deployment section.
Audit emission#
Auth events emit into audit_event like every other event. The full taxonomy is in Observability; the auth-specific entries:
| event_type | Trigger | Payload (excerpt) |
|---|---|---|
auth.login.success |
Human login OK | {ip, user_agent} |
auth.login.failure |
Any login failure | {ip, user_agent, reason, username_attempted} |
auth.login.rate_limited |
Login bucket exhausted | {ip, username_attempted, bucket} |
auth.logout |
Explicit logout | {session_id} |
auth.session.expired |
Auto-expiry on resolve | {session_id, reason: 'idle'\|'absolute'} |
auth.bot.success |
Bot auth OK | {token_id, token_prefix, surface: 'rest'\|'mcp'} |
auth.bot.failure |
Bot auth failed | {token_prefix, surface, reason} |
auth.bot.write_attempt_in_read_mode |
Read bot tried a write tool/endpoint | {surface, target_kind, target_id} |
auth.password.changed |
Self-service password change | {username} — actor_kind='human' |
auth.password.reset_via_cli |
CLI password reset | {username} — actor_kind='system' |
bot.created / bot.updated / bot.enabled / bot.disabled |
Bot lifecycle events | {bot_user_id, ...} |
bot.token.created / bot.token.rotated / bot.token.revoked |
Token lifecycle | {bot_user_id, token_id, token_prefix} |
Every row carries request_id for cross-actor correlation. A human's PATCH that rotates a bot token produces a bot.token.rotated whose request_id ties to the human's audit row for the same request.
Auth failure events bypass the unit of work#
Auth-failure events write immediately on a dedicated short-lived connection (audit_sink.write_now), not via the after-commit hook used for domain events. The failure itself is the security signal; it must persist even if the surrounding request transaction rolls back.
See Observability for the full pattern and the audit.orphaned defense net.
Bot authentication flow (REST)#
sequenceDiagram
autonumber
participant Bot
participant API as FastAPI middleware
participant Auth as auth/tokens.py
participant DB
participant Svc as service
Bot->>API: GET /api/v1/tickets, X-API-Key: tkb_abc...xyz
API->>Auth: resolve_bot_by_token("tkb_abc...xyz")
Auth->>DB: SELECT bot_token WHERE token_prefix='abc' AND is_active=true
DB-->>Auth: row(token_hash, bot_user_id)
Auth->>Auth: argon2.verify(row.token_hash, "tkb_abc...xyz")
Auth-->>API: CurrentActor (bot)
API->>API: attach to request.state
API->>Svc: ticket_service.list_tickets(actor, filters)
Svc->>Svc: filter by actor.bot_project_allowlist
Svc->>DB: SELECT tickets WHERE project_id IN (...) AND ...
DB-->>Svc: rows
Svc-->>API: TicketSummary[]
API-->>Bot: 200 { data, page }
Token rotation by a human#
sequenceDiagram
autonumber
participant Human
participant API as FastAPI
participant Svc as bot_service
participant Tokens as auth/tokens.py
participant DB
Human->>API: POST /api/v1/bots/{id}/tokens/rotate (cookie)
API->>Svc: rotate_token(actor=human, bot_id)
Svc->>Tokens: generate_plaintext_token() → "tkb_..."
Svc->>Tokens: argon2_hash("tkb_...") → hash
Svc->>DB: BEGIN
Svc->>DB: UPDATE bot_token SET is_active=false, rotated_at=now() WHERE bot_user_id=? AND is_active=true
Svc->>DB: INSERT bot_token (bot_user_id, token_hash, token_prefix, is_active=true)
Svc->>DB: COMMIT
Svc->>DB: (after-commit) INSERT audit_event(bot.token.rotated)
Svc-->>API: { plaintext_token, new_token_row }
API-->>Human: 200 { plaintext_token: "tkb_...", token_id, prefix }
Note over Human,API: Plaintext is shown ONCE.
What this page does not cover#
- Frontend cookie handling (how Next.js attaches cookies on fetch) —
spec/06_frontend.md. - The REST endpoint catalog and which dependencies they use — see the REST API reference.
- MCP tool authorization specifics — see the MCP server reference.
- TLS termination, HSTS specifics, reverse-proxy configuration — see Deployment.