excel file update

This commit is contained in:
sriram
2026-08-27 16:41:25 +05:30
parent a9ab1fcf71
commit f698720ee2
7 changed files with 411 additions and 4 deletions

View File

@@ -55,7 +55,27 @@ AUTH_LOCKOUT_SECONDS=300
# anyone who finds mcp.nearle.ai.in signs in as admin by typing anything at all.
AUTH_ALLOW_ANY_LOGIN=false
# Machine consumers. Empty: MCP clients authenticate with a login token instead.
# Machine consumers. `name:role:secret` triples, comma-separated; keyed by the
# secret, so deleting one entry revokes exactly one caller and leaves the rest
# working. Role MUST be `admin` for the store-catalog / catalog-generate /
# training routes: those guard with require_admin, which is a ROLE check, and
# `admin` is a superuser - a key here unlocks every admin endpoint, not just
# the one it was issued for.
#
# DELIBERATELY EMPTY HERE. The real value lives in the Dokploy Environment tab:
#
# 1. This file is committed. It already carries the DB password, S3 keys and
# the token-signing secret; a per-consumer API key is the one credential
# that gets issued and revoked often, and it does not belong in git.
# 2. Dockerfile does `COPY .env.production .env`, so a value here is baked at
# BUILD time - issuing or revoking a key would mean rebuilding an image
# that installs CPU torch, which has already failed once on disk space.
# settings.py calls load_dotenv() WITHOUT override=True, so the tab's value
# wins and takes effect on a plain restart.
#
# Consequence: /api/health reports api_keys_source "process-env" for this one,
# and that is correct here, not a warning. A value set below would be silently
# ignored while the tab is populated - so leave it empty.
API_KEYS=
# --- Postgres / pgvector ---------------------------------------------------

View File

@@ -0,0 +1,128 @@
# Deployment configuration for mcp.nearle.ai.in.
#
# Committed at the repo owner's instruction so the deploy does not depend on
# re-entering config in the Dokploy UI. Everything needed to boot is here; no
# environment variables are required in Dokploy any more.
#
# A real environment variable still overrides anything set here - settings.py
# calls load_dotenv() without override=True, so the process environment wins.
# That is the escape hatch for changing a value without a commit.
#
# WHAT IS IN THIS FILE: live database, S3 and Google credentials, and the key
# that signs every access token. Anyone with read access to this repository has
# all of it, and git history keeps it after any rotation.
# --- Ports -----------------------------------------------------------------
# Dokploy routes the domain to 3000; 8000 is kept for the vite dev proxy and
# docker-compose. serve.py binds both.
PORTS=3000,8000
# --- CORS ------------------------------------------------------------------
# The FRONTEND's origin, not this API's. Wrong value = the browser blocks every
# response while the server logs healthy 200s - which is exactly what happened
# here: this was set to catalogue.nearle.ai.in, but the domain Traefik actually
# serves is spelled "catalouge". That host does not even resolve, so nothing
# pointed at the mistake except a silently failing UI.
#
# Both spellings are listed so this keeps working if the typo is ever corrected
# in Dokploy. Exact origins, never a wildcard: the app sends an Authorization
# header, and browsers reject credentialed requests to a wildcard origin.
API_CORS_ORIGINS=https://catalouge.nearle.ai.in,https://catalogue.nearle.ai.in
# --- Authentication --------------------------------------------------------
AUTH_ENABLED=true
# One interactive account: admin. The `user` account is disabled here by
# leaving AUTH_USER_PASSWORD_HASH unset - auth.py omits any account whose
# hash is empty, so only admin can sign in.
#
# AUTH_SECRET_KEY stays as generated for this deployment; rotating it would
# invalidate every token already issued.
# Sign-in password for the hash below: admin / admin123.
AUTH_SECRET_KEY=4Kmyr4Cjf_kdUIq_4EGxo5vFHfCT5_uKVR3eouszB8Le6F0n45m7eDY94_KJoqSz
AUTH_ADMIN_USERNAME=admin
AUTH_ADMIN_PASSWORD_HASH=pbkdf2_sha256$600000$S28AccXqQnNNElilb0JFsg==$IGPLr56iqwkwbrM5skVoXBMDFEfpZIKUA7NiaM74cmk=
# AUTH_USER_USERNAME=user
# AUTH_USER_PASSWORD_HASH= (unset: the `user` account is disabled)
AUTH_TOKEN_TTL_MINUTES=720
AUTH_MAX_LOGIN_ATTEMPTS=10
AUTH_LOCKOUT_SECONDS=300
# MUST stay false here. The development .env has this true, where it is a
# convenience: it skips the password check entirely, so any username signs in
# and `admin` gets the admin pages. On a host published to the internet it means
# anyone who finds mcp.nearle.ai.in signs in as admin by typing anything at all.
AUTH_ALLOW_ANY_LOGIN=false
# Machine consumers. Empty: MCP clients authenticate with a login token instead.
API_KEYS=
# --- Postgres / pgvector ---------------------------------------------------
# DB_NAME is not set in the development .env, so it falls back to settings.py's
# default. Stated explicitly here so the deployment does not depend on that
# default staying the same.
USE_PGVECTOR=true
DB_HOST=31.97.228.132
DB_PORT=6054
DB_NAME=pgvector
DB_USER=admin
# The single quotes are PART OF THE PASSWORD, not shell/dotenv syntax. The outer
# double quotes are what dotenv strips, leaving 'Package@321#' including quotes.
# Writing it bare as Package@321# is what made every connection fail with
# "password authentication failed for user admin", which surfaces as
# /api/health reporting "database": false and an empty catalog on every page -
# the API looks healthy and the database looks empty. Do not "tidy" the quotes.
DB_PASSWORD="'Package@321#'"
# --- Embeddings ------------------------------------------------------------
USE_EMBEDDINGS=true
EMBEDDINGS_MODEL=sentence-transformers/all-MiniLM-L6-v2
EMBEDDINGS_DIM=384
# --- Ollama (local LLM, powers /api/chat) ----------------------------------
# Off, because the development value (http://localhost:11434) cannot work from
# inside a container: there, localhost is the container itself, not the VPS
# host. Left on with nothing listening, /api/chat fails AND every healthcheck
# takes ~3s longer, because the health handler probes Ollama with a 3s timeout.
#
# To enable: set USE_OLLAMA=true and point OLLAMA_BASE_URL at something the
# container can actually reach - http://host.docker.internal:11434 with a
# host-gateway mapping, the VPS's LAN IP, or an ollama service name.
USE_OLLAMA=false
OLLAMA_BASE_URL=http://host.docker.internal:11434
OLLAMA_MODEL_NAME=qwen2.5:1.5b
OLLAMA_TIMEOUT_SECONDS=120
# --- DigitalOcean Spaces (product image storage) ---------------------------
USE_S3=true
S3_ACCESS_KEY=DO801G8Q8JAZKF49U3WJ
S3_SECRET_KEY=lBQExYfkVqH+ybmGVmQH5MkThBbrIohA/VQLgcPUvug
S3_ENDPOINT=https://nearle.sgp1.digitaloceanspaces.com
S3_BUCKET=nearle
S3_REGION=sgp1
# --- Google Custom Search (optional image source) --------------------------
USE_GOOGLE_CSE=true
GOOGLE_API_KEY=AIzaSyBY4pIO_Fp5FCMqeVxDNcfalzdWNHJWVn0
GOOGLE_CSE_ID=9745cbd96dd164562
# --- Open-source image sources (no key needed) -----------------------------
USE_DDG_IMAGES=true
USE_OPEN_FACTS=true
USE_WIKIMEDIA=true
# The Playwright browser binary is NOT installed in the image (see Dockerfile),
# so this tier is skipped at runtime regardless. false stops it being attempted.
USE_PLAYWRIGHT_FALLBACK=false
MIN_IMAGE_BYTES=3000
# --- Product validation ----------------------------------------------------
ENABLE_PRODUCT_VALIDATION=true
VALIDATION_REJECT_THRESHOLD=0.35
VALIDATION_REVIEW_THRESHOLD=0.70
# --- RAG -------------------------------------------------------------------
RAG_DEFAULT_TOP_K=5
RAG_MAX_TOP_K=15
RAG_MAX_CONTEXT_CHARS=4000

View File

@@ -63,6 +63,20 @@ class SourceProductOut(BaseModel):
# Health
# ---------------------------------------------------------------------------
class ApiKeyInfoOut(BaseModel):
"""One configured machine consumer, named but never quoted.
`fingerprint` is a truncated digest of name+secret, not the secret. It exists
so a caller who was issued a key can confirm THAT key is the one this
deployment loaded - the question a 401 cannot answer, since an undeployed key
and a wrong key fail identically.
"""
name: str
role: str
fingerprint: str
class AuthConfigOut(BaseModel):
"""
The effective auth configuration, reported by /api/health.
@@ -73,7 +87,9 @@ class AuthConfigOut(BaseModel):
already the documented one, allow_any_login=true is a fact an operator
urgently needs (and an attacker discovers with a single login attempt
anyway), and the fingerprint is a truncated hash of a salted digest, not a
password. What it buys is a one-command answer to "is this deployment
password. The API key block follows the same rule: it names which consumers
are configured and fingerprints their keys, so a caller can tell an
undeployed key from a rejected one, but it never renders a secret. What it buys is a one-command answer to "is this deployment
running the config I think it is?" - compare the fingerprint here against
the one printed by scripts/make_auth_secrets.py --fingerprint.
"""
@@ -87,6 +103,13 @@ class AuthConfigOut(BaseModel):
# "process-env" | "env-file" | "default" - which one actually won.
admin_username_source: str
password_hash_source: str
# Machine consumers. Names and fingerprints only - the secrets themselves are
# never rendered here, and _parse_api_keys enforces enough entropy that the
# fingerprints do not give them away. Defaulted so a client of this schema
# still validates against a deployment predating these fields.
api_keys_count: int = 0
api_keys: List[ApiKeyInfoOut] = Field(default_factory=list)
api_keys_source: str = "default"
class HealthOut(BaseModel):

View File

@@ -186,6 +186,40 @@ def password_hash_fingerprint(encoded: str) -> str:
return hashlib.sha256(encoded.strip().strip("'\"").encode("utf-8")).hexdigest()[:12]
def api_key_fingerprint(name: str, secret: str) -> str:
"""
A short, non-reversible identifier for a configured API key.
Same purpose as password_hash_fingerprint - say *which* credential is loaded
without moving the credential - but the safety argument is different and
worth stating. That function digests an encoded hash which already embeds a
16-byte random salt. An API key has no salt, so the name is mixed in here to
keep two consumers that were mistakenly issued the same secret from
fingerprinting identically, and settings._parse_api_keys enforces a minimum
secret length so the digest cannot be walked back with a wordlist.
"""
if not secret:
return ""
cleaned = secret.strip().strip("'\"")
material = f"{name}:{cleaned}"
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:12]
def describe_api_keys() -> List[Dict[str, object]]:
"""Every configured key as {name, role, fingerprint}, sorted by name.
Sorted so two deployments' /api/health output can be diffed line for line;
API_KEYS is keyed by secret, whose iteration order says nothing useful.
"""
return sorted(
(
{"name": name, "role": role, "fingerprint": api_key_fingerprint(name, secret)}
for secret, (name, role) in API_KEYS.items()
),
key=lambda entry: entry["name"],
)
def describe_password_hash(encoded: str) -> Dict[str, object]:
"""A loggable/publishable summary of a configured digest. Never its bytes."""
parsed = _parse_encoded_hash(encoded)
@@ -211,6 +245,13 @@ def auth_config_summary() -> Dict[str, object]:
means the container's own environment supplied it and the .env file baked
into the image was ignored - which is invisible from anywhere else, and is
precisely how a corrected credential can keep failing after a redeploy.
The same argument is why the API keys are summarised here. backend/Dockerfile
copies .env.production in at BUILD time, so a key added to that file and then
merely restarted is not present in the running process - and from outside,
an undeployed key is indistinguishable from a wrong one, because both are
just a 401. Publishing the names and fingerprints answers "is my key on this
deployment?" without anyone having to send the secret to find out.
"""
described = describe_password_hash(AUTH_ADMIN_PASSWORD_HASH)
return {
@@ -222,6 +263,9 @@ def auth_config_summary() -> Dict[str, object]:
"password_hash_fingerprint": described["fingerprint"],
"admin_username_source": config_source("AUTH_ADMIN_USERNAME"),
"password_hash_source": config_source("AUTH_ADMIN_PASSWORD_HASH"),
"api_keys_count": len(API_KEYS),
"api_keys": describe_api_keys(),
"api_keys_source": config_source("API_KEYS"),
}

View File

@@ -386,6 +386,12 @@ AUTH_LOCKOUT_SECONDS = int(os.getenv("AUTH_LOCKOUT_SECONDS", "300"))
AUTH_ALLOW_ANY_LOGIN = _bool("AUTH_ALLOW_ANY_LOGIN", "false")
# Shortest acceptable API key secret. token_urlsafe(32) yields 43 characters, so
# this rejects hand-typed values without rejecting anything the documented
# generator produces.
API_KEY_MIN_LENGTH = 32
def _parse_api_keys(raw: str) -> dict:
"""
Parse ``API_KEYS`` - ``name:role:secret`` triples, comma-separated.
@@ -393,6 +399,16 @@ def _parse_api_keys(raw: str) -> dict:
Keyed by secret because that is what an inbound request presents. One entry
per consumer is the point: a shared key cannot be revoked for one caller
without breaking all of them.
Secrets must be at least API_KEY_MIN_LENGTH characters. That is not about
guessing the key over the network - the lockout and the network itself make
online brute force impractical - but about what /api/health publishes. It
reports a truncated digest of every configured key so a deployment can be
checked against the config it was built from, and a digest of a *raw* secret
is only safe when the secret is unguessable offline. An admin password hash
embeds a random salt, so its fingerprint discloses nothing; an API key has no
salt, and a hand-picked "changeme" would fall to a wordlist in seconds.
Generate one with: python -c "import secrets; print(secrets.token_urlsafe(32))"
"""
parsed: dict = {}
for entry in raw.split(","):
@@ -412,6 +428,13 @@ def _parse_api_keys(raw: str) -> dict:
)
if not secret:
raise RuntimeError(f"API_KEYS entry {name!r} has an empty secret.")
if len(secret) < API_KEY_MIN_LENGTH:
raise RuntimeError(
f"API_KEYS entry {name!r} has a {len(secret)}-character secret; at least "
f"{API_KEY_MIN_LENGTH} are required, because /api/health publishes a digest "
f"of it. Generate one with: "
f"python -c \"import secrets; print(secrets.token_urlsafe(32))\""
)
parsed[secret] = (name, role)
return parsed

View File

@@ -205,7 +205,8 @@ if API_CORS_ORIGINS and all(
_auth_cfg = auth_config_summary()
logger.info(
"Auth config: enabled=%s allow_any_login=%s admin_username=%r "
"hash=%s/%s fingerprint=%s source=%s (username source=%s)",
"hash=%s/%s fingerprint=%s source=%s (username source=%s) "
"api_keys=%d/%s %s",
_auth_cfg["enabled"],
_auth_cfg["allow_any_login"],
_auth_cfg["admin_username"],
@@ -214,6 +215,11 @@ logger.info(
_auth_cfg["password_hash_fingerprint"] or "(none)",
_auth_cfg["password_hash_source"],
_auth_cfg["admin_username_source"],
_auth_cfg["api_keys_count"],
_auth_cfg["api_keys_source"],
# Names, not secrets. A key added to .env.production but only restarted into
# a running container never appears here - which is the whole point.
[k["name"] for k in _auth_cfg["api_keys"]] or "(none)",
)
if cleaned_env_names():
logger.warning(

View File

@@ -21,13 +21,15 @@ from __future__ import annotations
import pytest
from app.infrastructure.security import (
api_key_fingerprint,
auth_config_summary,
describe_api_keys,
describe_password_hash,
hash_is_wellformed,
hash_password,
password_hash_fingerprint,
)
from app.infrastructure.settings import config_source
from app.infrastructure.settings import API_KEY_MIN_LENGTH, _parse_api_keys, config_source
from tests.conftest import TEST_ADMIN_PASSWORD
@@ -164,3 +166,164 @@ def test_health_stays_ok_shaped_when_auth_is_misconfigured(client, monkeypatch):
assert body["auth"]["password_hash_valid"] is False
assert body["status"] in {"ok", "degraded"} # decided by db/ollama only
# ---------------------------------------------------------------------------
# API keys
# ---------------------------------------------------------------------------
# Same incident, one layer out. A key added to .env.production and then merely
# restarted into a running container is absent from the process, because the
# Dockerfile copies that file in at BUILD time - and from outside, an undeployed
# key and a wrong key are both just a 401. These pin the ability to tell them
# apart without anyone sending the secret to find out.
_GOOD_SECRET = "cs3JwvApS5Je_Qfe1sNYq6YtUBDDeqp4OEgy2_41sQg"
def test_api_key_fingerprint_is_stable_and_hex():
fp = api_key_fingerprint("partner", _GOOD_SECRET)
assert fp == api_key_fingerprint("partner", _GOOD_SECRET)
assert len(fp) == 12
assert all(c in "0123456789abcdef" for c in fp)
def test_api_key_fingerprint_differs_when_the_secret_does():
assert api_key_fingerprint("partner", _GOOD_SECRET) != api_key_fingerprint(
"partner", _GOOD_SECRET[:-1] + "X"
)
def test_api_key_fingerprint_separates_consumers_sharing_a_secret():
"""The name is mixed in, so two consumers mistakenly issued the same secret
do not report the same fingerprint - which would hide the mistake behind the
very field meant to reveal it."""
assert api_key_fingerprint("console-a", _GOOD_SECRET) != api_key_fingerprint(
"console-b", _GOOD_SECRET
)
@pytest.mark.parametrize("wrapper", ["{}", "'{}'", '"{}"', " {} "])
def test_api_key_fingerprint_ignores_quotes_and_whitespace(wrapper):
"""A value pasted into a deployment platform's Environment tab arrives
wrapped often enough that settings strips it; the fingerprint must agree,
or comparing two ends reports a mismatch that is not real."""
assert api_key_fingerprint("partner", wrapper.format(_GOOD_SECRET)) == (
api_key_fingerprint("partner", _GOOD_SECRET)
)
def test_api_key_fingerprint_discloses_no_part_of_the_secret():
"""Served unauthenticated, so it must be a digest OF the key, not a piece."""
fp = api_key_fingerprint("partner", _GOOD_SECRET)
assert fp not in _GOOD_SECRET
for start in range(len(fp) - 5):
assert fp[start : start + 6] not in _GOOD_SECRET
def test_absent_secret_fingerprints_as_empty():
assert api_key_fingerprint("partner", "") == ""
def test_describe_api_keys_is_sorted_by_name(monkeypatch):
"""API_KEYS is keyed by secret, whose order says nothing. Sorting is what
lets two deployments' output be diffed line for line."""
from app.infrastructure import security
monkeypatch.setattr(
security, "API_KEYS",
{_GOOD_SECRET: ("zulu", "user"), _GOOD_SECRET[::-1]: ("alpha", "admin")},
)
assert [k["name"] for k in describe_api_keys()] == ["alpha", "zulu"]
assert [k["role"] for k in describe_api_keys()] == ["admin", "user"]
# ---------------------------------------------------------------------------
# API keys on /api/health
# ---------------------------------------------------------------------------
def test_health_reports_the_keys_this_deployment_actually_loaded(client):
"""Against the harness's own API_KEYS, not a monkeypatched one - this is the
end-to-end wiring from settings through the summary to the response body."""
from tests.conftest import TEST_API_KEY
auth = client.get("/api/health").json()["auth"]
assert auth["api_keys_count"] == 1
assert auth["api_keys"] == [{
"name": "test-machine",
"role": "user",
"fingerprint": api_key_fingerprint("test-machine", TEST_API_KEY),
}]
def test_health_reports_an_empty_list_when_no_keys_are_configured(client, monkeypatch):
"""The state production was in while the colleague's console got 401s: auth
enabled, admin login working, and not one machine consumer deployed."""
from app.infrastructure import security
monkeypatch.setattr(security, "API_KEYS", {})
auth = client.get("/api/health").json()["auth"]
assert auth["api_keys_count"] == 0
assert auth["api_keys"] == []
def test_health_names_configured_keys_and_fingerprints_them(client, monkeypatch):
from app.infrastructure import security
monkeypatch.setattr(security, "API_KEYS", {_GOOD_SECRET: ("colleague-console", "admin")})
auth = client.get("/api/health").json()["auth"]
assert auth["api_keys_count"] == 1
assert auth["api_keys"] == [{
"name": "colleague-console",
"role": "admin",
"fingerprint": api_key_fingerprint("colleague-console", _GOOD_SECRET),
}]
def test_health_never_exposes_an_api_key_secret(client, monkeypatch):
"""The leak canary, extended to machine credentials."""
from app.infrastructure import security
monkeypatch.setattr(security, "API_KEYS", {_GOOD_SECRET: ("colleague-console", "admin")})
body = client.get("/api/health").text
assert _GOOD_SECRET not in body
for start in range(0, len(_GOOD_SECRET) - 7):
assert _GOOD_SECRET[start : start + 8] not in body
def test_health_reports_where_the_keys_came_from(client):
"""Which of the two config sources won. Unlike the admin hash - where
"process-env" flags a stale Environment tab shadowing the image - API_KEYS is
deliberately supplied by that tab, so "process-env" is the expected value in
production and "env-file" would mean the tab entry has gone missing."""
auth = client.get("/api/health").json()["auth"]
assert auth["api_keys_source"] in {"process-env", "env-file", "default"}
# ---------------------------------------------------------------------------
# Parsing
# ---------------------------------------------------------------------------
def test_parse_api_keys_accepts_a_generated_secret():
parsed = _parse_api_keys(f"partner:admin:{_GOOD_SECRET}")
assert parsed == {_GOOD_SECRET: ("partner", "admin")}
def test_parse_api_keys_rejects_a_secret_too_short_to_fingerprint_safely():
"""A raw key carries no salt, so publishing its digest is only safe while the
key itself is unguessable offline. A hand-picked one must be refused at
startup rather than quietly fingerprinted onto a public endpoint."""
with pytest.raises(RuntimeError, match="at least"):
_parse_api_keys("partner:admin:changeme")
def test_parse_api_keys_length_limit_admits_the_documented_generator():
import secrets as _secrets
assert len(_secrets.token_urlsafe(32)) >= API_KEY_MIN_LENGTH