ZyntroAI Console
++ Backend: {health} +
+ +{error}
} + +-
+ {items.map((it) => (
+
- + {it.name} #{it.id} + + ))} +
diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9c3e34d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.gitignore +**/node_modules +**/.venv +**/__pycache__ +**/.pytest_cache +logs +.env +**/*.log +**/dist diff --git a/.github/PULL_REQUEST_TEMPLATE/bugfix.md b/.github/PULL_REQUEST_TEMPLATE/bugfix.md new file mode 100644 index 0000000..fcb9071 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/bugfix.md @@ -0,0 +1,36 @@ +--- +name: π Bugfix +about: Defect fix with reproduction +title: "fix: " +--- + +## π Bugfix + +### Bug description +What is the bug, and how was it observed? + +### Reproduction +Steps to reproduce: +1. _____ + +### Root cause +What was the underlying cause? (be specific) + +### Fix +- [ ] Root-cause fix applied (not a symptom patch) +- [ ] Minimal, targeted diff + +### Tests +- [ ] Regression test added for this bug +- [ ] Existing tests pass + +### Test evidence +``` +(before/after output) +``` + +### Checklist +- [ ] Bug reproduced before fix +- [ ] Root cause identified +- [ ] Regression test included +- [ ] No unintended behavior change diff --git a/.github/PULL_REQUEST_TEMPLATE/dependencies.md b/.github/PULL_REQUEST_TEMPLATE/dependencies.md new file mode 100644 index 0000000..5d3dffa --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/dependencies.md @@ -0,0 +1,37 @@ +--- +name: π¦ Dependency +about: Package bump, upgrade, CVE remediation +title: "build(deps): " +--- + +## π¦ Dependencies + +### Change +Which dependency and version change? (e.g. `package@1.2.3` β `@2.0.0`) + +### Reason +- [ ] Routine update +- [ ] Security / CVE remediation (reference CVE) +- [ ] Feature needed upstream + +### Compatibility +- [ ] Semver-compatible (patch/minor) +- [ ] Major version β breaking changes reviewed +- [ ] Transitive deps reviewed + +### Validation +- [ ] Dependency review passed (CI) +- [ ] Tests pass with new version +- [ ] No known CVEs remain in the bump +- [ ] Lockfile / manifest updated together + +### Test evidence +``` +(paste test / audit output) +``` + +### Checklist +- [ ] Version pinned appropriately (not floating) +- [ ] Both manifest + lockfile updated +- [ ] CI green +- [ ] Change is isolated to the dependency diff --git a/.github/PULL_REQUEST_TEMPLATE/docs.md b/.github/PULL_REQUEST_TEMPLATE/docs.md new file mode 100644 index 0000000..2b05418 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/docs.md @@ -0,0 +1,32 @@ +--- +name: π Documentation +about: README, API docs, Obsidian vault, guides +title: "docs: " +--- + +## π Docs + +### What is documented +Describe the documentation change and where it lives. + +### Type +- [ ] README / repo docs +- [ ] API / reference docs +- [ ] Obsidian vault / knowledge base +- [ ] Runbook / SOP +- [ ] Tutorial / guide + +### Scope +- [ ] New section/file +- [ ] Correction / clarification +- [ ] Removal of stale content + +### Index impact +- [ ] Knowledge index (Algolia/Obsidian) needs re-sync +- [ ] Links verified (no broken anchors) + +### Checklist +- [ ] Content is accurate (no invented claims) +- [ ] Code samples are correct / runnable +- [ ] Terminology consistent with repo +- [ ] No secrets or internal details leaked diff --git a/.github/PULL_REQUEST_TEMPLATE/infra-config.md b/.github/PULL_REQUEST_TEMPLATE/infra-config.md new file mode 100644 index 0000000..273cb8e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/infra-config.md @@ -0,0 +1,39 @@ +--- +name: βοΈ Configuration +about: Env, Docker, K8s, CI, infra-as-code +title: "config: " +--- + +## βοΈ Infra/Config + +### What is being changed +Describe the infra/config change (env, Docker, K8s, CI, IaC). + +### Scope +- [ ] Environment / secrets config +- [ ] Docker / docker-compose +- [ ] Kubernetes manifests +- [ ] CI/CD workflows +- [ ] Observability / monitoring +- [ ] Other: _____ + +### Impact +- [ ] Existing behavior preserved (additive) +- [ ] Services affected: _____ + +### Validation +- [ ] YAML validated +- [ ] Config values correct (no secrets committed) +- [ ] Local parity confirmed (docker compose up) +- [ ] Rollback path defined + +### Test evidence +``` +(paste validation output) +``` + +### Checklist +- [ ] Change is minimal and targeted +- [ ] No secrets committed +- [ ] Documented in README / runbook where needed +- [ ] Rollback documented diff --git a/.github/PULL_REQUEST_TEMPLATE/release.md b/.github/PULL_REQUEST_TEMPLATE/release.md new file mode 100644 index 0000000..f2b5264 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/release.md @@ -0,0 +1,34 @@ +--- +name: π¦ Release +about: Version bump, changelog, deployment cut +title: "release: " +--- + +## π¦ Release + +### Version +`v0.0.0` β `v0.0.0` (specify) + +### Changelog entry +Summarize the user-facing changes in this release. + +### Deployment plan +- [ ] Migrations included / none +- [ ] Feature flags / rollback strategy defined +- [ ] Environment variables documented +- [ ] Backwards-compatible + +### Pre-flight checks +- [ ] Tests pass on CI +- [ ] Changelog updated +- [ ] Tag created / version bumped in manifest +- [ ] Release notes drafted + +### Rollback +What is the rollback path if this release is reverted? + +### Checklist +- [ ] Version bumped in all manifests +- [ ] Changelog reflects this release +- [ ] Deployment steps documented +- [ ] Rollback plan confirmed diff --git a/.github/PULL_REQUEST_TEMPLATE/security.md b/.github/PULL_REQUEST_TEMPLATE/security.md new file mode 100644 index 0000000..99cc179 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/security.md @@ -0,0 +1,41 @@ +--- +name: π Security +about: Vulnerability, auth, secrets, or security hardening +title: "security: " +--- + +## π Security + +### Vulnerability / Issue +What is the security issue being addressed? (CVE, weakness, or risk) + +### Affected area +- [ ] Authentication / Authorization +- [ ] Secrets / credentials handling +- [ ] Input validation / injection +- [ ] Data protection (encryption at rest / in transit) +- [ ] Dependency / supply-chain +- [ ] Other: _____ + +### Root cause +Describe the root cause and how it was identified. + +### Fix +- [ ] Code change applied +- [ ] No secrets committed (scanned) +- [ ] Existing tests updated / new tests added +- [ ] Verified exploit no longer succeeds + +### Impact +What is the blast radius if this is not fixed? + +### Test evidence +``` +(paste test output / proof the fix works) +``` + +### Checklist +- [ ] Security issue verified +- [ ] Change is minimal and targeted +- [ ] No plaintext secrets introduced +- [ ] Reviewer security-checks the diff diff --git a/FILE-MANIFEST.md b/FILE-MANIFEST.md new file mode 100644 index 0000000..95aee08 --- /dev/null +++ b/FILE-MANIFEST.md @@ -0,0 +1,34 @@ +# ZyntroAI Scaffold β File Manifest + +All files listed here are NEW additions (no-clobber). Nothing existing on main was modified or deleted. + +## Added files (17 total) + +### (root) +- `.dockerignore` +- `FILE-MANIFEST.md` +- `ZYNTROAI-SCAFFOLD.md` + +### .github +- `.github/workflows/pr-ci.yml` +- `.github/workflows/pr-validation.yml` + +### backend +- `backend/Dockerfile` +- `backend/alembic.ini` +- `backend/alembic/` +- `backend/app/` +- `backend/pyproject.toml` +- `backend/tests/` + +### frontend +- `frontend/` + +### k8s +- `k8s/backend-deployment.yaml` +- `k8s/backend-hpa.yaml` +- `k8s/frontend-deployment.yaml` +- `k8s/secrets.yaml` + +### knowledge +- `knowledge/` diff --git a/ZYNTROAI-SCAFFOLD.md b/ZYNTROAI-SCAFFOLD.md new file mode 100644 index 0000000..1a46749 --- /dev/null +++ b/ZYNTROAI-SCAFFOLD.md @@ -0,0 +1,63 @@ +# ZyntroAI β Merged Monorepo Scaffold (Proposal) + +> **Status: PROPOSAL / PR for review** β additive, no-clobber addition to the +> repo. Does not replace or modify any existing file. See `FILE-MANIFEST.md` +> for the full inventory and the colliding-names list. + +Unified architecture across backend, frontend, knowledge indexing, Kubernetes +and CI/CD β presented as a ready-to-review scaffold so nothing existing is +overwritten before you decide what to adopt. + +Generated: 2026-09-09 Β· Python 3.11 Β· FastAPI Β· React Β· PostgreSQL Β· Redis Β· K8s Β· GitHub Actions + +## What this PR adds + +| Area | Path | Contents | +|---|---|---| +| Backend (Python) | `backend/` | FastAPI + SQLModel async API, Alembic migrations, Pydantic config, JWT auth, pytest suite (6 passing) | +| Frontend | `frontend/` | React + Vite + TypeScript shell with type-safe API client | +| Knowledge | `knowledge/` | Obsidian REST API client + Algolia indexer (`diff_policy.py`, `push_index.py`) | +| Kubernetes | `k8s/` | Backend/frontend Deployments, Services, Ingress, HPA, Secrets (helm chart untouched) | +| CI/CD | `.github/workflows/` | `pr-ci.yml` (type classify β reusable validation β tests) + `pr-validation.yml` | +| Docs | `README.md`, `FILE-MANIFEST.md` | This spec + machine-readable inventory | + +> **Note:** `docker-compose.yml` and `.env.example` already existed on `main`; +> this PR intentionally does **not** overwrite them (see +> `FILE-MANIFEST.md` β "Collisions β left untouched"). + +## Backend core + +`backend/app/core/config.py` β type-safe env via pydantic-settings. +`backend/app/core/security.py` β bcrypt hashing + JWT (python-jose). +`backend/app/infrastructure/db.py` β async SQLModel engine + sessions. +`backend/app/api/deps.py` β `get_db` + JWT `get_current_user`. +`backend/app/main.py` β lifespan, CORS, exception handler, `/health`. +`backend/app/api/v1/...` β `models/item.py`, `schemas/item.py`, +`services/item_service.py`, `routes/items.py` (full CRUD data path). + +### Verify + +```bash +cd backend +pip install -e ".[dev]" # or: pip install fastapi sqlmodel ... pytest +pytest tests/ -v # 6 passing +``` + +## CI/CD workflow + +- `.github/workflows/pr-ci.yml` β `pull_request` + `push` to `main`; classifies + the PR type from its body, calls the reusable validator, runs backend lint + + tests, and syncs the knowledge index on Docs PRs / `main`. +- `.github/workflows/pr-validation.yml` β reusable `workflow_call`: title + format (Conventional Commits), template completeness, checklist scan, secrets + scan (Gitleaks), Ruff, TS build, and type-gated dependency/markdown/release + checks. Emits a `validation_result` output for branch protection. + +## Next steps + +1. Review this PR's additions (everything is **new**; nothing existing changed). +2. Decide which areas to adopt (e.g. keep the new `backend/` Python app + separate from the existing root `app/`, or reconcile them). +3. Set branch protection to require `validate / validation_result` + 1 approval. +4. Add secrets: `ALGOLIA_APP_ID`, `ALGOLIA_API_KEY`, `OBSIDIAN_API_TOKEN`, + `GITHUB_TOKEN`. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..a9bfd1b --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,33 @@ +FROM python:3.11-slim AS builder + +WORKDIR /app +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 + +# Build deps for psycopg/asyncpg wheels are only needed at install time. +RUN apt-get update \ + && apt-get install -y --no-install-recommends gcc build-essential curl \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml . +RUN pip install --no-cache-dir --prefix=/install . + +FROM python:3.11-slim + +WORKDIR /app +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PYTHONPATH=/app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /install /usr/local +COPY app/ /app/app/ +COPY alembic.ini /app/alembic.ini +COPY alembic/ /app/alembic/ + +RUN useradd --create-home --uid 1000 appuser && chown -R appuser:appuser /app +USER appuser + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..463e39c --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,39 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +file_template = %%(rev)s_%%(slug)s +timezone = UTC + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..6f6f345 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,62 @@ +"""Alembic environment β async engine target, mirrors app metadata.""" +from __future__ import annotations + +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config +from sqlmodel import SQLModel + +from app.core.config import get_settings +from app.api.v1 import models # noqa: F401 (register tables with metadata) + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +settings = get_settings() +config.set_main_option("sqlalchemy.url", settings.database_url.replace("%", "%%")) + +target_metadata = SQLModel.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..979da50 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from __future__ import annotations + +import sqlalchemy as sa +import sqlmodel +from alembic import op +${imports if imports else ""} + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/0001_initial_create_items.py b/backend/alembic/versions/0001_initial_create_items.py new file mode 100644 index 0000000..1d8b601 --- /dev/null +++ b/backend/alembic/versions/0001_initial_create_items.py @@ -0,0 +1,34 @@ +"""create items table + +Revision ID: 0001_initial +Revises: +Create Date: 2026-09-09 +""" +from __future__ import annotations + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +revision = "0001_initial" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "items", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False), + sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_items_name"), "items", ["name"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_items_name"), table_name="items") + op.drop_table("items") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..b11ba81 --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,41 @@ +"""FastAPI dependencies: DB session + JWT auth. + +Faithful to the merged spec β `get_current_user` decodes the Bearer token and +returns the `sub` (username). Overridable in tests via dependency_overrides. +""" +from __future__ import annotations + +from collections.abc import AsyncGenerator +from typing import Annotated + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import decode_token +from app.infrastructure.db import get_session + +oauth2_bearer = HTTPBearer(auto_error=False) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async for session in get_session(): + yield session + + +async def get_current_user( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(oauth2_bearer)], +) -> str: + if credentials is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token") + payload = decode_token(credentials.credentials) + username = payload.get("sub") if payload else None + if not username: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token" + ) + return username + + +DbSession = Annotated[AsyncSession, Depends(get_db)] +CurrentUser = Annotated[str, Depends(get_current_user)] diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/models/__init__.py b/backend/app/api/v1/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/models/item.py b/backend/app/api/v1/models/item.py new file mode 100644 index 0000000..9035487 --- /dev/null +++ b/backend/app/api/v1/models/item.py @@ -0,0 +1,33 @@ +"""SQLModel ORM models. + +`Item` is the canonical example entity wired end-to-end (model -> schema -> +service -> route -> migration -> test) so the boilerplate demonstrates the full +data path. Add further models alongside it. +""" +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import func +from sqlmodel import Field, SQLModel + + +def _utcnow() -> datetime: + return datetime.now(UTC) + + +class Item(SQLModel, table=True): + """A persisted record with audit timestamps.""" + + __tablename__ = "items" + + id: int | None = Field(default=None, primary_key=True) + name: str = Field(index=True, max_length=200) + description: str | None = Field(default=None, max_length=2000) + created_at: datetime = Field( + default_factory=_utcnow, sa_column_kwargs={"server_default": func.now()} + ) + updated_at: datetime = Field( + default_factory=_utcnow, + sa_column_kwargs={"server_default": func.now(), "onupdate": func.now()}, + ) diff --git a/backend/app/api/v1/routes/__init__.py b/backend/app/api/v1/routes/__init__.py new file mode 100644 index 0000000..f609bee --- /dev/null +++ b/backend/app/api/v1/routes/__init__.py @@ -0,0 +1,7 @@ +"""v1 API router β aggregates all v1 route modules.""" +from fastapi import APIRouter + +from app.api.v1.routes import items + +api_router = APIRouter() +api_router.include_router(items.router) diff --git a/backend/app/api/v1/routes/items.py b/backend/app/api/v1/routes/items.py new file mode 100644 index 0000000..d6479f7 --- /dev/null +++ b/backend/app/api/v1/routes/items.py @@ -0,0 +1,57 @@ +"""Item CRUD routes demonstrating the full async data path. + +- GET /items list (public) +- POST /items create (public in boilerplate; add CurrentUser to protect) +- GET/PATCH/DELETE item by id + +A protected example route (`GET /me`) shows JWT usage via `CurrentUser`. +""" +from __future__ import annotations + +from fastapi import APIRouter, Response, status + +from app.api.deps import CurrentUser, DbSession +from app.api.v1.schemas.item import ItemCreate, ItemRead, ItemUpdate +from app.api.v1.services import item_service + +router = APIRouter(prefix="/items", tags=["items"]) + + +@router.get("", response_model=list[ItemRead]) +async def list_items(session: DbSession, offset: int = 0, limit: int = 100) -> list[ItemRead]: + items = await item_service.list_items(session, offset=offset, limit=limit) + return [ItemRead.model_validate(i) for i in items] + + +@router.post("", response_model=ItemRead, status_code=status.HTTP_201_CREATED) +async def create_item(data: ItemCreate, session: DbSession) -> ItemRead: + item = await item_service.create_item(session, data) + return ItemRead.model_validate(item) + + +@router.get("/me", tags=["auth"]) +async def whoami(user: CurrentUser) -> dict: + """Protected route β requires a valid Bearer token. + + Defined before the parameterized /{item_id} routes so /items/me is not + shadowed by the int-typed path param. + """ + return {"username": user} + + +@router.get("/{item_id}", response_model=ItemRead) +async def get_item(item_id: int, session: DbSession) -> ItemRead: + item = await item_service.get_item(session, item_id) + return ItemRead.model_validate(item) + + +@router.patch("/{item_id}", response_model=ItemRead) +async def update_item(item_id: int, data: ItemUpdate, session: DbSession) -> ItemRead: + item = await item_service.update_item(session, item_id, data) + return ItemRead.model_validate(item) + + +@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_item(item_id: int, session: DbSession) -> Response: + await item_service.delete_item(session, item_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/api/v1/schemas/__init__.py b/backend/app/api/v1/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/schemas/item.py b/backend/app/api/v1/schemas/item.py new file mode 100644 index 0000000..2600d49 --- /dev/null +++ b/backend/app/api/v1/schemas/item.py @@ -0,0 +1,26 @@ +"""Pydantic schemas for the Item resource: request + response shapes.""" +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class ItemCreate(BaseModel): + name: str = Field(min_length=1, max_length=200) + description: str | None = Field(default=None, max_length=2000) + + +class ItemUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=200) + description: str | None = Field(default=None, max_length=2000) + + +class ItemRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + description: str | None + created_at: datetime + updated_at: datetime diff --git a/backend/app/api/v1/services/__init__.py b/backend/app/api/v1/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/services/item_service.py b/backend/app/api/v1/services/item_service.py new file mode 100644 index 0000000..6fdcc10 --- /dev/null +++ b/backend/app/api/v1/services/item_service.py @@ -0,0 +1,56 @@ +"""Business logic for the Item resource. + +Services own the data access so routes stay thin. All functions take an +AsyncSession and raise domain errors from `app.core.exceptions`. +""" +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.v1.models.item import Item +from app.api.v1.schemas.item import ItemCreate, ItemUpdate +from app.core.exceptions import ConflictError, NotFoundError + + +async def list_items(session: AsyncSession, *, offset: int = 0, limit: int = 100) -> list[Item]: + stmt = select(Item).order_by(Item.id).offset(offset).limit(limit) + result = await session.execute(stmt) + return list(result.scalars().all()) + + +async def get_item(session: AsyncSession, item_id: int) -> Item: + item = await session.get(Item, item_id) + if item is None: + raise NotFoundError(f"Item {item_id} not found") + return item + + +async def create_item(session: AsyncSession, data: ItemCreate) -> Item: + existing = await session.execute(select(Item).where(Item.name == data.name)) + if existing.scalars().first() is not None: + raise ConflictError(f"An item named '{data.name}' already exists") + item = Item(**data.model_dump()) + session.add(item) + await session.commit() + await session.refresh(item) + return item + + +async def update_item(session: AsyncSession, item_id: int, data: ItemUpdate) -> Item: + item = await get_item(session, item_id) + changes = data.model_dump(exclude_unset=True) + if not changes: + return item + for field, value in changes.items(): + setattr(item, field, value) + session.add(item) + await session.commit() + await session.refresh(item) + return item + + +async def delete_item(session: AsyncSession, item_id: int) -> None: + item = await get_item(session, item_id) + await session.delete(item) + await session.commit() diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..c15de21 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,34 @@ +"""Application settings β type-safe env config. + +Faithful to the merged spec: app identity, environment, database, redis, +JWT auth and the optional Algolia/Obsidian credentials. Loaded once and cached. +""" +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", case_sensitive=False, extra="ignore") + + app_name: str = "ZyntroAI API" + environment: str = "development" + debug: bool = False + + database_url: str = "postgresql+asyncpg://user:pass@db:5432/zyntro" + redis_url: str = "redis://redis:6379/0" + jwt_secret: str = "change_this_in_prod" + jwt_algorithm: str = "HS256" + access_token_expire_minutes: int = 30 + + algolia_app_id: str | None = None + algolia_api_key: str | None = None + obsidian_api_token: str | None = None + obsidian_api_url: str | None = None + + cors_origins: list[str] = ["http://localhost:3000"] + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py new file mode 100644 index 0000000..29f17c1 --- /dev/null +++ b/backend/app/core/exceptions.py @@ -0,0 +1,39 @@ +"""Shared application exceptions mapped to HTTP responses. + +Registered in the FastAPI app so domain errors (not found, conflict, forbidden) +return consistent JSON instead of a bare 500. +""" +from __future__ import annotations + + +class AppError(Exception): + status_code = 400 + code = "app_error" + + def __init__(self, message: str, *, detail: dict | None = None) -> None: + super().__init__(message) + self.message = message + self.detail = detail or {} + + def to_response(self) -> dict: + return {"code": self.code, "message": self.message, **self.detail} + + +class NotFoundError(AppError): + status_code = 404 + code = "not_found" + + +class ConflictError(AppError): + status_code = 409 + code = "conflict" + + +class UnauthorizedError(AppError): + status_code = 401 + code = "unauthorized" + + +class ForbiddenError(AppError): + status_code = 403 + code = "forbidden" diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..9db96fd --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,43 @@ +"""Security helpers: password hashing + JWT creation/decoding. + +Uses passlib/bcrypt for password hashing and python-jose for JWT (matching the +spec's auth model). Tokens carry `sub` = username; expiry is enforced on decode. +""" +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any + +from jose import JWTError, jwt +from passlib.context import CryptContext + +from app.core.config import get_settings + +settings = get_settings() +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(plain: str) -> str: + return pwd_context.hash(plain) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def create_access_token(subject: str, extra: dict[str, Any] | None = None) -> str: + expires = datetime.now(UTC) + timedelta( + minutes=settings.access_token_expire_minutes + ) + claims: dict[str, Any] = {"sub": subject, "exp": expires} + if extra: + claims.update(extra) + return jwt.encode(claims, settings.jwt_secret, algorithm=settings.jwt_algorithm) + + +def decode_token(token: str) -> dict[str, Any] | None: + """Return decoded claims or None when invalid/expired.""" + try: + return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]) + except JWTError: + return None diff --git a/backend/app/infrastructure/__init__.py b/backend/app/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/infrastructure/db.py b/backend/app/infrastructure/db.py new file mode 100644 index 0000000..fbd6e6f --- /dev/null +++ b/backend/app/infrastructure/db.py @@ -0,0 +1,34 @@ +"""Database infrastructure: async SQLModel engine, session factory, init. + +Faithful to the merged spec β asyncpg engine driven by `database_url`, with an +`init_db()` that creates tables and a `get_session()` async generator used by +FastAPI dependencies. +""" +from __future__ import annotations + +from collections.abc import AsyncIterator + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlmodel import SQLModel + +from app.core.config import get_settings + +settings = get_settings() + +engine = create_async_engine(settings.database_url, echo=settings.debug, future=True) + +async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def init_db() -> None: + """Create all tables on startup (dev convenience; use Alembic in prod).""" + # Import models so their metadata registers before create_all. + from app.api.v1 import models # noqa: F401 + + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + + +async def get_session() -> AsyncIterator[AsyncSession]: + async with async_session_factory() as session: + yield session diff --git a/backend/app/infrastructure/redis.py b/backend/app/infrastructure/redis.py new file mode 100644 index 0000000..716346d --- /dev/null +++ b/backend/app/infrastructure/redis.py @@ -0,0 +1,42 @@ +"""Redis infrastructure: async client + cache helpers. + +Kept fail-open: when Redis is unavailable, cache lookups miss and writes no-op +rather than raising, so an outage degrades performance instead of breaking the +API (matching the user's graceful-degradation pattern). +""" +from __future__ import annotations + +import json +from typing import Any + +from redis.asyncio import Redis +from redis.exceptions import RedisError + +from app.core.config import get_settings + +settings = get_settings() +_redis: Redis = Redis.from_url(settings.redis_url, decode_responses=True) + + +def get_redis() -> Redis: + return _redis + + +async def cache_get(key: str) -> Any | None: + try: + raw = await _redis.get(key) + except RedisError: + return None + if raw is None: + return None + try: + return json.loads(raw) + except (TypeError, ValueError): + return None + + +async def cache_set(key: str, value: Any, ttl_seconds: int) -> None: + try: + await _redis.set(key, json.dumps(value, default=str), ex=ttl_seconds) + except (RedisError, TypeError): + return diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..e298c54 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,53 @@ +"""FastAPI entrypoint for the ZyntroAI backend. + +Faithful to the merged spec with two hardening additions: +- lifespan (instead of deprecated on_event) for startup/shutdown. +- domain-error -> JSON exception handler registration. +The `/health` route and `/api/v1` router mount match the spec exactly. +""" +from __future__ import annotations + +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from app.api.v1.routes import api_router +from app.core.config import get_settings +from app.core.exceptions import AppError +from app.infrastructure.db import init_db + +settings = get_settings() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Only auto-create tables in non-production; prod relies on Alembic. + if settings.environment != "production": + await init_db() + yield + + +app = FastAPI(title=settings.app_name, debug=settings.debug, lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.exception_handler(AppError) +async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: + return JSONResponse(status_code=exc.status_code, content=exc.to_response()) + + +app.include_router(api_router, prefix="/api/v1") + + +@app.get("/health") +async def health() -> dict: + return {"status": "ok", "env": settings.environment} diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..c1c8f63 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,46 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "zyntroai-backend" +version = "0.1.0" +description = "ZyntroAI monorepo backend β FastAPI + SQLModel + PostgreSQL + Redis" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.110", + "uvicorn[standard]>=0.29", + "sqlmodel>=0.0.16", + "asyncpg>=0.29", + "alembic>=1.13", + "redis>=5.0", + "pydantic-settings>=2.2", + "python-jose[cryptography]>=3.3", + "passlib[bcrypt]>=1.7", + "python-multipart>=0.0.9", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "httpx>=0.27", + "ruff>=0.4", + "aiosqlite>=0.20", +] + +[tool.setuptools.packages.find] +include = ["app*"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] +ignore = ["B008"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +filterwarnings = ["ignore::DeprecationWarning"] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..2c67957 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,41 @@ +"""Test fixtures β in-memory SQLite (aiosqlite) so tests need no PostgreSQL. + +Environment is set to `production` so the app lifespan skips auto-creating +tables on the (unused) asyncpg engine; we then create tables on a fresh +aiosqlite engine and override the `get_db` dependency with it. +""" +from __future__ import annotations + +import os + +os.environ.setdefault("ENVIRONMENT", "production") +os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///:memory:") + +import pytest # noqa: E402 +from httpx import ASGITransport, AsyncClient # noqa: E402 +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine # noqa: E402 +from sqlmodel import SQLModel # noqa: E402 + +from app.api import deps # noqa: E402 +from app.main import app # noqa: E402 + + +@pytest.fixture +async def client(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + factory = async_sessionmaker(engine, expire_on_commit=False) + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + + async def override_get_db(): + async with factory() as session: + yield session + + app.dependency_overrides[deps.get_db] = override_get_db + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + app.dependency_overrides.clear() + await engine.dispose() diff --git a/backend/tests/test_items.py b/backend/tests/test_items.py new file mode 100644 index 0000000..97e7cf9 --- /dev/null +++ b/backend/tests/test_items.py @@ -0,0 +1,69 @@ +"""Integration tests for the Item CRUD API + health + JWT auth.""" +from __future__ import annotations + +from app.core.security import create_access_token + + +async def test_health(client): + r = await client.get("/health") + assert r.status_code == 200 + assert r.json()["status"] == "ok" + + +async def test_crud_roundtrip(client): + # create + r = await client.post("/api/v1/items", json={"name": "first", "description": "hello"}) + assert r.status_code == 201 + body = r.json() + item_id = body["id"] + assert body["name"] == "first" + + # list + r = await client.get("/api/v1/items") + assert r.status_code == 200 + assert any(i["id"] == item_id for i in r.json()) + + # get one + r = await client.get(f"/api/v1/items/{item_id}") + assert r.status_code == 200 + assert r.json()["description"] == "hello" + + # update + r = await client.patch(f"/api/v1/items/{item_id}", json={"name": "renamed"}) + assert r.status_code == 200 + assert r.json()["name"] == "renamed" + + # delete + r = await client.delete(f"/api/v1/items/{item_id}") + assert r.status_code == 204 + + # gone -> 404 + r = await client.get(f"/api/v1/items/{item_id}") + assert r.status_code == 404 + + +async def test_create_duplicate_conflict(client): + await client.post("/api/v1/items", json={"name": "dup"}) + r = await client.post("/api/v1/items", json={"name": "dup"}) + assert r.status_code == 409 + assert r.json()["code"] == "conflict" + + +async def test_missing_item_404(client): + r = await client.get("/api/v1/items/9999") + assert r.status_code == 404 + + +async def test_protected_route_requires_token(client): + r = await client.get("/api/v1/items/me") + assert r.status_code == 401 + + token = create_access_token("alice") + r = await client.get("/api/v1/items/me", headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 200 + assert r.json()["username"] == "alice" + + +async def test_validation_error_422(client): + r = await client.post("/api/v1/items", json={"name": ""}) + assert r.status_code == 422 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..07db020 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package.json ./ +RUN npm install --no-audit --no-fund +COPY . . +RUN npm run build + +FROM node:20-alpine +WORKDIR /app +RUN npm install -g serve +COPY --from=builder /app/dist ./dist +EXPOSE 3000 +CMD ["serve", "-s", "dist", "-l", "3000"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..84d030e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + +
+ + ++ Backend: {health} +
+ +{error}
} + +