first commit

This commit is contained in:
龙澳
2026-03-23 14:18:24 +08:00
parent 99bf38bcde
commit 7045866ef3
7 changed files with 828 additions and 177 deletions

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
AUTH_DB_PATH=~/.nanobot/auth_service.sqlite3
AUTH_JWT_SECRET=change-this-secret
AUTH_TOKEN_TTL_HOURS=24
AUTH_CORS_ORIGINS=*
AUTH_INVITE_CODES=invite-a,invite-b
AUTH_ADMIN_KEY=change-this-admin-key
AUTH_HOST=0.0.0.0
AUTH_PORT=9100

355
.gitignore vendored
View File

@@ -1,176 +1,179 @@
# ---> Python # custom gitignore for nanobot-auth-service
# Byte-compiled / optimized / DLL files *.sqlite3
__pycache__/
*.py[cod] # ---> Python
*$py.class # Byte-compiled / optimized / DLL files
__pycache__/
# C extensions *.py[cod]
*.so *$py.class
# Distribution / packaging # C extensions
.Python *.so
build/
develop-eggs/ # Distribution / packaging
dist/ .Python
downloads/ build/
eggs/ develop-eggs/
.eggs/ dist/
lib/ downloads/
lib64/ eggs/
parts/ .eggs/
sdist/ lib/
var/ lib64/
wheels/ parts/
share/python-wheels/ sdist/
*.egg-info/ var/
.installed.cfg wheels/
*.egg share/python-wheels/
MANIFEST *.egg-info/
.installed.cfg
# PyInstaller *.egg
# Usually these files are written by a python script from a template MANIFEST
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest # PyInstaller
*.spec # Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
# Installer logs *.manifest
pip-log.txt *.spec
pip-delete-this-directory.txt
# Installer logs
# Unit test / coverage reports pip-log.txt
htmlcov/ pip-delete-this-directory.txt
.tox/
.nox/ # Unit test / coverage reports
.coverage htmlcov/
.coverage.* .tox/
.cache .nox/
nosetests.xml .coverage
coverage.xml .coverage.*
*.cover .cache
*.py,cover nosetests.xml
.hypothesis/ coverage.xml
.pytest_cache/ *.cover
cover/ *.py,cover
.hypothesis/
# Translations .pytest_cache/
*.mo cover/
*.pot
# Translations
# Django stuff: *.mo
*.log *.pot
local_settings.py
db.sqlite3 # Django stuff:
db.sqlite3-journal *.log
local_settings.py
# Flask stuff: db.sqlite3
instance/ db.sqlite3-journal
.webassets-cache
# Flask stuff:
# Scrapy stuff: instance/
.scrapy .webassets-cache
# Sphinx documentation # Scrapy stuff:
docs/_build/ .scrapy
# PyBuilder # Sphinx documentation
.pybuilder/ docs/_build/
target/
# PyBuilder
# Jupyter Notebook .pybuilder/
.ipynb_checkpoints target/
# IPython # Jupyter Notebook
profile_default/ .ipynb_checkpoints
ipython_config.py
# IPython
# pyenv profile_default/
# For a library or package, you might want to ignore these files since the code is ipython_config.py
# intended to run in multiple environments; otherwise, check them in:
# .python-version # pyenv
# For a library or package, you might want to ignore these files since the code is
# pipenv # intended to run in multiple environments; otherwise, check them in:
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # .python-version
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not # pipenv
# install all needed dependencies. # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
#Pipfile.lock # However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# UV # install all needed dependencies.
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. #Pipfile.lock
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries. # UV
#uv.lock # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# poetry # commonly ignored for libraries.
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. #uv.lock
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries. # poetry
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
#poetry.lock # This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# pdm # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #poetry.lock
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # pdm
# in version control. # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control #pdm.lock
.pdm.toml # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
.pdm-python # in version control.
.pdm-build/ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm .pdm-python
__pypackages__/ .pdm-build/
# Celery stuff # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
celerybeat-schedule __pypackages__/
celerybeat.pid
# Celery stuff
# SageMath parsed files celerybeat-schedule
*.sage.py celerybeat.pid
# Environments # SageMath parsed files
.env *.sage.py
.venv
env/ # Environments
venv/ .env
ENV/ .venv
env.bak/ env/
venv.bak/ venv/
ENV/
# Spyder project settings env.bak/
.spyderproject venv.bak/
.spyproject
# Spyder project settings
# Rope project settings .spyderproject
.ropeproject .spyproject
# mkdocs documentation # Rope project settings
/site .ropeproject
# mypy # mkdocs documentation
.mypy_cache/ /site
.dmypy.json
dmypy.json # mypy
.mypy_cache/
# Pyre type checker .dmypy.json
.pyre/ dmypy.json
# pytype static type analyzer # Pyre type checker
.pytype/ .pyre/
# Cython debug symbols # pytype static type analyzer
cython_debug/ .pytype/
# PyCharm # Cython debug symbols
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can cython_debug/
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear # PyCharm
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
#.idea/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# Ruff stuff: # option (not recommended) you can uncomment the following to ignore the entire idea folder.
.ruff_cache/ #.idea/
# PyPI configuration file # Ruff stuff:
.pypirc .ruff_cache/
# PyPI configuration file
.pypirc

View File

@@ -1,2 +1,91 @@
# nanobot-auth # nanobot-auth-service
Standalone phone/password auth service for nanobot web chat.
## Features
- `POST /auth/register` (phone + password + invite code; returns pending)
- `POST /auth/login`
- `GET /auth/me` (Bearer token)
- `GET /auth/register/status/{request_id}`
- `GET /admin/requests` (admin key required)
- `POST /admin/requests/{id}/approve` (admin key required)
- `POST /admin/requests/{id}/reject` (admin key required)
- SQLite persistence
- JWT access tokens
## Quick Start
```bash
cd nanobot-auth-service
pip install -e .
cp .env.example .env
source .env
uvicorn app.main:app --host ${AUTH_HOST:-0.0.0.0} --port ${AUTH_PORT:-9100}
```
## Env Vars
- `AUTH_DB_PATH`: sqlite file path
- `AUTH_JWT_SECRET`: JWT signing secret
- `AUTH_TOKEN_TTL_HOURS`: access token ttl
- `AUTH_CORS_ORIGINS`: comma-separated origins or `*`
- `AUTH_INVITE_CODES`: comma-separated whitelist (empty means no whitelist check)
- `AUTH_ADMIN_KEY`: required by admin endpoints
- `AUTH_HOST`: bind host (run command)
- `AUTH_PORT`: bind port (run command)
## API Contract
`POST /auth/register`
```json
{
"phone": "13800000000",
"password": "secret123",
"invite_code": "invite-a"
}
```
Response:
```json
{
"ok": true,
"status": "pending",
"request_id": 1,
"message": "pending review"
}
```
Manual approval flow (operator terminal):
```bash
cd nanobot-auth-service
python app/manual_review.py
```
The script lists pending requests and asks for each item:
- input `y` => approve and create user
- input `r` => reject with reason
- any other input => skip
`POST /auth/login` has the same request/response shape.
`GET /auth/me`
Header:
```http
Authorization: Bearer <token>
```
Response:
```json
{
"ok": true,
"user": {"id": 1, "phone": "13800000000"}
}
```

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
__all__ = []

421
app/main.py Normal file
View File

@@ -0,0 +1,421 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import os
import re
import secrets
import sqlite3
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
import jwt
from fastapi import Depends, FastAPI, Header, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
PHONE_PATTERN = re.compile(r"^\d{6,20}$")
AUTH_DB_PATH = os.getenv("AUTH_DB_PATH", "auth_service.sqlite3")
AUTH_JWT_SECRET = os.getenv("AUTH_JWT_SECRET", "change-this-secret")
AUTH_TOKEN_TTL_HOURS = int(os.getenv("AUTH_TOKEN_TTL_HOURS", "24"))
AUTH_CORS_ORIGINS = os.getenv("AUTH_CORS_ORIGINS", "*")
AUTH_INVITE_CODES = {
item.strip()
for item in os.getenv("AUTH_INVITE_CODES", "").split(",")
if item.strip()
}
AUTH_ADMIN_KEY = os.getenv("AUTH_ADMIN_KEY", "")
def _ensure_db(path_str: str) -> Path:
path = Path(path_str).expanduser()
path.parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(path) as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
created_at TEXT NOT NULL,
last_login_at TEXT
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS registration_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
invite_code TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
reviewed_at TEXT,
review_note TEXT
)
"""
)
conn.commit()
return path
DB_PATH = _ensure_db(AUTH_DB_PATH)
class AuthPayload(BaseModel):
phone: str
password: str
class RegisterPayload(AuthPayload):
invite_code: str
class TokenResponse(BaseModel):
ok: bool
access_token: str
token_type: str = "bearer"
expires_in: int
user: dict
class RegisterPendingResponse(BaseModel):
ok: bool
status: str
request_id: int
message: str
def _connect() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def _normalize_phone(phone: str) -> str:
p = str(phone).strip()
# Accept common user input formats in test environments, then normalize.
p = p.replace(" ", "").replace("-", "")
if p.startswith("+86"):
p = p[3:]
if not PHONE_PATTERN.match(p):
raise HTTPException(status_code=400, detail="phone must be 6-20 digits")
return p
def _validate_password(password: str) -> None:
if len(password) < 6:
raise HTTPException(status_code=400, detail="password must be at least 6 characters")
def _hash_password(password: str, salt: bytes) -> str:
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 200_000)
return base64.b64encode(digest).decode("ascii")
def _create_access_token(user_id: int, phone: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"uid": user_id,
"sub": phone,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(hours=max(1, AUTH_TOKEN_TTL_HOURS))).timestamp()),
}
return jwt.encode(payload, AUTH_JWT_SECRET, algorithm="HS256")
def _token_response(user_id: int, phone: str) -> TokenResponse:
token = _create_access_token(user_id, phone)
return TokenResponse(
ok=True,
access_token=token,
expires_in=max(1, AUTH_TOKEN_TTL_HOURS) * 3600,
user={"id": user_id, "phone": phone},
)
def _extract_bearer(authorization: str | None) -> str:
if not authorization:
raise HTTPException(status_code=401, detail="missing authorization")
prefix = "Bearer "
if not authorization.startswith(prefix):
raise HTTPException(status_code=401, detail="invalid authorization")
token = authorization[len(prefix):].strip()
if not token:
raise HTTPException(status_code=401, detail="missing token")
return token
def _current_user(authorization: str | None = Header(default=None)) -> dict:
token = _extract_bearer(authorization)
try:
payload = jwt.decode(token, AUTH_JWT_SECRET, algorithms=["HS256"])
except jwt.PyJWTError as e:
raise HTTPException(status_code=401, detail="invalid token") from e
uid = int(payload.get("uid", 0))
phone = str(payload.get("sub", ""))
if uid <= 0 or not phone:
raise HTTPException(status_code=401, detail="invalid token payload")
return {"id": uid, "phone": phone}
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _require_invite_code(invite_code: str) -> str:
code = str(invite_code).strip()
if not code:
raise HTTPException(status_code=400, detail="invite code is required")
if AUTH_INVITE_CODES and code not in AUTH_INVITE_CODES:
raise HTTPException(status_code=400, detail="invalid invite code")
return code
def _admin_ok(x_admin_key: str | None = Header(default=None)) -> None:
if not AUTH_ADMIN_KEY:
raise HTTPException(status_code=500, detail="AUTH_ADMIN_KEY is not configured")
if x_admin_key != AUTH_ADMIN_KEY:
raise HTTPException(status_code=401, detail="invalid admin key")
def _approve_request_with_conn(conn: sqlite3.Connection, request_id: int, note: str = "approved") -> None:
row = conn.execute(
"""
SELECT id, phone, password_hash, salt, status
FROM registration_requests
WHERE id = ?
""",
(request_id,),
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="request not found")
if row["status"] != "pending":
raise HTTPException(status_code=400, detail=f"request already {row['status']}")
try:
conn.execute(
"""
INSERT INTO users(phone, password_hash, salt, created_at)
VALUES(?, ?, ?, ?)
""",
(row["phone"], row["password_hash"], row["salt"], _now_iso()),
)
conn.execute(
"""
UPDATE registration_requests
SET status = 'approved', reviewed_at = ?, review_note = ?
WHERE id = ?
""",
(_now_iso(), note, request_id),
)
conn.commit()
except sqlite3.IntegrityError as e:
conn.execute(
"""
UPDATE registration_requests
SET status = 'rejected', reviewed_at = ?, review_note = ?
WHERE id = ?
""",
(_now_iso(), "phone already exists", request_id),
)
conn.commit()
raise HTTPException(status_code=400, detail="phone already exists") from e
def _reject_request_with_conn(conn: sqlite3.Connection, request_id: int, note: str) -> None:
row = conn.execute(
"SELECT id, status FROM registration_requests WHERE id = ?",
(request_id,),
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="request not found")
if row["status"] != "pending":
raise HTTPException(status_code=400, detail=f"request already {row['status']}")
conn.execute(
"""
UPDATE registration_requests
SET status = 'rejected', reviewed_at = ?, review_note = ?
WHERE id = ?
""",
(_now_iso(), note or "rejected", request_id),
)
conn.commit()
app = FastAPI(title="nanobot-auth-service", version="0.1.0")
origins = ["*"] if AUTH_CORS_ORIGINS.strip() == "*" else [o.strip() for o in AUTH_CORS_ORIGINS.split(",") if o.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
def health() -> dict:
return {
"ok": True,
"service": "auth",
"db": str(DB_PATH),
"invite_code_check": bool(AUTH_INVITE_CODES),
}
@app.post("/auth/register", response_model=RegisterPendingResponse)
def register(payload: RegisterPayload):
phone = _normalize_phone(payload.phone)
_validate_password(payload.password)
invite_code = _require_invite_code(payload.invite_code)
salt = secrets.token_bytes(16)
password_hash = _hash_password(payload.password, salt)
now = _now_iso()
try:
with _connect() as conn:
existing = conn.execute(
"SELECT id FROM users WHERE phone = ?",
(phone,),
).fetchone()
if existing is not None:
raise HTTPException(status_code=400, detail="phone already exists")
pending = conn.execute(
"SELECT id FROM registration_requests WHERE phone = ? AND status = 'pending'",
(phone,),
).fetchone()
if pending is not None:
return RegisterPendingResponse(
ok=True,
status="pending",
request_id=int(pending["id"]),
message="pending review",
)
cur = conn.execute(
"""
INSERT INTO registration_requests(phone, password_hash, salt, invite_code, status, created_at)
VALUES(?, ?, ?, ?, 'pending', ?)
""",
(phone, password_hash, base64.b64encode(salt).decode("ascii"), invite_code, now),
)
request_id = int(cur.lastrowid)
conn.commit()
except HTTPException:
raise
except sqlite3.IntegrityError as e:
raise HTTPException(status_code=400, detail="phone already exists") from e
return RegisterPendingResponse(
ok=True,
status="pending",
request_id=request_id,
message="pending review",
)
@app.get("/auth/register/status/{request_id}")
def register_status(request_id: int):
with _connect() as conn:
row = conn.execute(
"""
SELECT id, phone, status, review_note
FROM registration_requests
WHERE id = ?
""",
(request_id,),
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="request not found")
return {
"ok": True,
"request_id": int(row["id"]),
"phone": row["phone"],
"status": row["status"],
"review_note": row["review_note"] or "",
}
@app.post("/auth/login", response_model=TokenResponse)
def login(payload: AuthPayload):
phone = _normalize_phone(payload.phone)
with _connect() as conn:
row = conn.execute(
"SELECT id, phone, password_hash, salt FROM users WHERE phone = ?",
(phone,),
).fetchone()
if row is None:
raise HTTPException(status_code=401, detail="invalid credentials")
salt = base64.b64decode(row["salt"].encode("ascii"))
expected = row["password_hash"]
actual = _hash_password(payload.password, salt)
if not hmac.compare_digest(expected, actual):
raise HTTPException(status_code=401, detail="invalid credentials")
conn.execute(
"UPDATE users SET last_login_at = ? WHERE id = ?",
(datetime.now(timezone.utc).isoformat(), int(row["id"])),
)
conn.commit()
return _token_response(int(row["id"]), str(row["phone"]))
@app.get("/auth/me")
def me(user: dict = Depends(_current_user)):
return {"ok": True, "user": user}
@app.get("/admin/requests")
def admin_requests(status_filter: str = "pending", _admin: None = Depends(_admin_ok)):
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, phone, invite_code, status, created_at, reviewed_at, review_note
FROM registration_requests
WHERE status = ?
ORDER BY id ASC
""",
(status_filter,),
).fetchall()
items: list[dict[str, Any]] = []
for r in rows:
items.append(
{
"id": int(r["id"]),
"phone": r["phone"],
"invite_code": r["invite_code"],
"status": r["status"],
"created_at": r["created_at"],
"reviewed_at": r["reviewed_at"],
"review_note": r["review_note"],
}
)
return {"ok": True, "items": items}
@app.post("/admin/requests/{request_id}/approve")
def admin_approve(request_id: int, _admin: None = Depends(_admin_ok)):
with _connect() as conn:
_approve_request_with_conn(conn, request_id, note="approved by admin")
return {"ok": True, "status": "approved", "request_id": request_id}
@app.post("/admin/requests/{request_id}/reject")
def admin_reject(request_id: int, note: str = "rejected by admin", _admin: None = Depends(_admin_ok)):
with _connect() as conn:
_reject_request_with_conn(conn, request_id, note=note)
return {"ok": True, "status": "rejected", "request_id": request_id}

110
app/manual_review.py Normal file
View File

@@ -0,0 +1,110 @@
from __future__ import annotations
import os
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
DB_PATH = Path(os.getenv("AUTH_DB_PATH", "auth_service.sqlite3")).expanduser()
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def connect() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def approve(conn: sqlite3.Connection, request_id: int) -> None:
row = conn.execute(
"SELECT id, phone, password_hash, salt, status FROM registration_requests WHERE id = ?",
(request_id,),
).fetchone()
if row is None:
print(f"request #{request_id} not found")
return
if row["status"] != "pending":
print(f"request #{request_id} already {row['status']}")
return
try:
conn.execute(
"INSERT INTO users(phone, password_hash, salt, created_at) VALUES(?, ?, ?, ?)",
(row["phone"], row["password_hash"], row["salt"], now_iso()),
)
conn.execute(
"UPDATE registration_requests SET status='approved', reviewed_at=?, review_note=? WHERE id=?",
(now_iso(), "approved in manual_review", request_id),
)
conn.commit()
print(f"approved request #{request_id} ({row['phone']})")
except sqlite3.IntegrityError:
conn.execute(
"UPDATE registration_requests SET status='rejected', reviewed_at=?, review_note=? WHERE id=?",
(now_iso(), "phone already exists", request_id),
)
conn.commit()
print(f"rejected request #{request_id}: phone already exists")
def reject(conn: sqlite3.Connection, request_id: int, note: str) -> None:
row = conn.execute(
"SELECT id, status, phone FROM registration_requests WHERE id = ?",
(request_id,),
).fetchone()
if row is None:
print(f"request #{request_id} not found")
return
if row["status"] != "pending":
print(f"request #{request_id} already {row['status']}")
return
conn.execute(
"UPDATE registration_requests SET status='rejected', reviewed_at=?, review_note=? WHERE id=?",
(now_iso(), note, request_id),
)
conn.commit()
print(f"rejected request #{request_id} ({row['phone']})")
def main() -> None:
if not DB_PATH.exists():
print(f"db not found: {DB_PATH}")
return
with connect() as conn:
rows = conn.execute(
"""
SELECT id, phone, invite_code, created_at
FROM registration_requests
WHERE status='pending'
ORDER BY id ASC
"""
).fetchall()
if not rows:
print("no pending requests")
return
print(f"pending requests: {len(rows)}")
for row in rows:
print("-" * 64)
print(
f"#{row['id']} phone={row['phone']} invite={row['invite_code']} "
f"created_at={row['created_at']}"
)
answer = input("approve this request? [y/N/r]: ").strip().lower()
if answer == "y":
approve(conn, int(row["id"]))
elif answer == "r":
note = input("reject reason: ").strip() or "rejected manually"
reject(conn, int(row["id"]), note)
else:
print("skip")
if __name__ == "__main__":
main()

19
pyproject.toml Normal file
View File

@@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "nanobot-auth-service"
version = "0.1.0"
description = "Standalone auth service for nanobot web channel"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.116.0",
"uvicorn>=0.35.0",
"PyJWT>=2.8.0"
]
[tool.setuptools.packages.find]
where = ["."]
include = ["app*"]