@ripla/godd-mcp 0.1.2-beta-20260312091403
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +412 -0
- package/dist/godd.cjs +465 -0
- package/dist/godd.js +34845 -0
- package/dist/index.js +224 -0
- package/notes-api/.env.example +29 -0
- package/notes-api/README.md +65 -0
- package/notes-api/alembic/README +1 -0
- package/notes-api/alembic/env.py +62 -0
- package/notes-api/alembic/script.py.mako +28 -0
- package/notes-api/alembic/versions/001_initial_notes_tables.py +71 -0
- package/notes-api/alembic.ini +149 -0
- package/notes-api/app/__init__.py +1 -0
- package/notes-api/app/config.py +90 -0
- package/notes-api/app/database.py +31 -0
- package/notes-api/app/main.py +55 -0
- package/notes-api/app/models/__init__.py +17 -0
- package/notes-api/app/models/audit.py +38 -0
- package/notes-api/app/models/base.py +38 -0
- package/notes-api/app/models/session.py +32 -0
- package/notes-api/app/models/setting.py +16 -0
- package/notes-api/app/models/user.py +38 -0
- package/notes-api/app/routers/__init__.py +1 -0
- package/notes-api/app/routers/auth.py +205 -0
- package/notes-api/app/routers/files.py +76 -0
- package/notes-api/app/routers/pr.py +92 -0
- package/notes-api/app/routers/settings.py +44 -0
- package/notes-api/app/routers/tree.py +22 -0
- package/notes-api/app/routers/users.py +145 -0
- package/notes-api/app/schemas/__init__.py +1 -0
- package/notes-api/app/schemas/auth.py +45 -0
- package/notes-api/app/schemas/user.py +41 -0
- package/notes-api/app/security.py +94 -0
- package/notes-api/app/services/__init__.py +1 -0
- package/notes-api/app/services/audit.py +25 -0
- package/notes-api/app/services/business_date.py +38 -0
- package/notes-api/app/services/github.py +140 -0
- package/notes-api/app/services/github_pr.py +120 -0
- package/notes-api/app/services/pr_chain.py +225 -0
- package/notes-api/app/startup.py +66 -0
- package/notes-api/docker/Dockerfile +14 -0
- package/notes-api/docker/Dockerfile.test +14 -0
- package/notes-api/pyproject.toml +53 -0
- package/notes-api/tests/conftest.py +21 -0
- package/notes-api/tests/test_business_date.py +32 -0
- package/notes-api/tests/test_health.py +11 -0
- package/notes-api/uv.lock +1012 -0
- package/notes-app/README.md +30 -0
- package/notes-app/docker/Dockerfile.prod +12 -0
- package/notes-app/docker/Dockerfile.test +12 -0
- package/notes-app/eslint.config.js +23 -0
- package/notes-app/index.html +12 -0
- package/notes-app/nginx.conf +23 -0
- package/notes-app/package.json +37 -0
- package/notes-app/pnpm-lock.yaml +3124 -0
- package/notes-app/src/App.tsx +13 -0
- package/notes-app/src/main.tsx +13 -0
- package/notes-app/src/pages/LoginPage.test.tsx +15 -0
- package/notes-app/src/pages/LoginPage.tsx +10 -0
- package/notes-app/src/pages/MainPage.tsx +14 -0
- package/notes-app/src/styles/globals.css +1 -0
- package/notes-app/src/vite-env.d.ts +1 -0
- package/notes-app/tsconfig.json +24 -0
- package/notes-app/tsconfig.node.json +10 -0
- package/notes-app/vite.config.js +21 -0
- package/notes-app/vite.config.ts +22 -0
- package/notes-app/vitest.config.ts +16 -0
- package/package.json +58 -0
- package/stacks/django-vue.yaml +33 -0
- package/stacks/fastapi-react.yaml +160 -0
- package/stacks/flutter-firebase.yaml +58 -0
- package/stacks/nextjs-prisma.yaml +33 -0
- package/stacks/schema.json +144 -0
- package/templates/agents/architect.hbs +183 -0
- package/templates/agents/auto-documenter.hbs +36 -0
- package/templates/agents/backend-developer.hbs +175 -0
- package/templates/agents/code-reviewer.hbs +29 -0
- package/templates/agents/database-developer.hbs +52 -0
- package/templates/agents/debug-specialist.hbs +39 -0
- package/templates/agents/environment-setup.hbs +41 -0
- package/templates/agents/frontend-developer.hbs +45 -0
- package/templates/agents/integration-qa.hbs +26 -0
- package/templates/agents/performance-qa.hbs +24 -0
- package/templates/agents/pr-writer.hbs +53 -0
- package/templates/agents/quality-lead.hbs +26 -0
- package/templates/agents/requirements-analyst.hbs +26 -0
- package/templates/agents/security-analyst.hbs +21 -0
- package/templates/agents/security-reviewer.hbs +20 -0
- package/templates/agents/ssot-updater.hbs +45 -0
- package/templates/agents/technical-writer.hbs +28 -0
- package/templates/agents/unit-test-engineer.hbs +19 -0
- package/templates/agents/user-clone.hbs +43 -0
- package/templates/github-actions/aws/deploy-notes.yml.hbs +118 -0
- package/templates/github-actions/azure/deploy-notes.yml.hbs +78 -0
- package/templates/github-actions/gcp/deploy-notes.yml.hbs +76 -0
- package/templates/github-actions/vercel-railway/deploy-notes.yml.hbs +49 -0
- package/templates/mindsets/agent-stdio.hbs +51 -0
- package/templates/mindsets/dart.hbs +39 -0
- package/templates/mindsets/ddd-clean-architecture.hbs +52 -0
- package/templates/mindsets/electron.hbs +41 -0
- package/templates/mindsets/fastapi.hbs +40 -0
- package/templates/mindsets/flutter.hbs +56 -0
- package/templates/mindsets/kotlin.hbs +46 -0
- package/templates/mindsets/postgresql.hbs +39 -0
- package/templates/mindsets/python.hbs +39 -0
- package/templates/mindsets/react.hbs +51 -0
- package/templates/mindsets/swift.hbs +46 -0
- package/templates/mindsets/terraform.hbs +53 -0
- package/templates/mindsets/typescript.hbs +34 -0
- package/templates/mindsets/vite.hbs +37 -0
- package/templates/notes-compose.yml +79 -0
- package/templates/partials/cogito-geo.hbs +84 -0
- package/templates/partials/cogito-godd.hbs +22 -0
- package/templates/partials/cogito-grammar.hbs +105 -0
- package/templates/partials/cogito-particle.hbs +76 -0
- package/templates/partials/cogito-root-ai.hbs +79 -0
- package/templates/partials/cogito-root-arch.hbs +75 -0
- package/templates/partials/cogito-root-core.hbs +75 -0
- package/templates/partials/cogito-root-daily.hbs +58 -0
- package/templates/partials/cogito-root-dev.hbs +71 -0
- package/templates/partials/cogito-root-devops.hbs +71 -0
- package/templates/partials/cogito-root-framework.hbs +80 -0
- package/templates/partials/cogito-root-lang.hbs +74 -0
- package/templates/partials/godd-core.hbs +50 -0
- package/templates/partials/project-context.hbs +52 -0
- package/templates/partials/ssot-header.hbs +40 -0
- package/templates/partials/ssot-rules.hbs +30 -0
- package/templates/prompts/adr.hbs +39 -0
- package/templates/prompts/agent-dev-workflow.hbs +34 -0
- package/templates/prompts/analyze-report.hbs +36 -0
- package/templates/prompts/auto-documentation.hbs +37 -0
- package/templates/prompts/check.hbs +52 -0
- package/templates/prompts/commit.hbs +96 -0
- package/templates/prompts/dev.hbs +127 -0
- package/templates/prompts/docs-init.hbs +95 -0
- package/templates/prompts/docs-update.hbs +51 -0
- package/templates/prompts/git-workflow.hbs +125 -0
- package/templates/prompts/impact-analysis.hbs +43 -0
- package/templates/prompts/install.hbs +40 -0
- package/templates/prompts/new-branch.hbs +61 -0
- package/templates/prompts/notes-deploy.hbs +72 -0
- package/templates/prompts/pr-analyze.hbs +44 -0
- package/templates/prompts/pr-create.hbs +125 -0
- package/templates/prompts/push-execute.hbs +83 -0
- package/templates/prompts/push.hbs +24 -0
- package/templates/prompts/release-notes.hbs +38 -0
- package/templates/prompts/requirements-to-business-flow.hbs +29 -0
- package/templates/prompts/requirements-to-tickets.hbs +26 -0
- package/templates/prompts/review.hbs +126 -0
- package/templates/prompts/setup.hbs +247 -0
- package/templates/prompts/spec.hbs +35 -0
- package/templates/prompts/specification-to-tickets.hbs +24 -0
- package/templates/prompts/submit.hbs +143 -0
- package/templates/prompts/sync.hbs +63 -0
- package/templates/prompts/test-plan.hbs +56 -0
- package/templates/terraform/aws/alb.tf.hbs +58 -0
- package/templates/terraform/aws/backend.tf.hbs +10 -0
- package/templates/terraform/aws/cloudfront.tf.hbs +96 -0
- package/templates/terraform/aws/ecr.tf.hbs +30 -0
- package/templates/terraform/aws/ecs.tf.hbs +136 -0
- package/templates/terraform/aws/iam.tf.hbs +88 -0
- package/templates/terraform/aws/local.tf.hbs +34 -0
- package/templates/terraform/aws/output.tf.hbs +49 -0
- package/templates/terraform/aws/provider.tf.hbs +26 -0
- package/templates/terraform/aws/rds.tf.hbs +38 -0
- package/templates/terraform/aws/s3.tf.hbs +34 -0
- package/templates/terraform/aws/secrets.tf.hbs +38 -0
- package/templates/terraform/aws/security_groups.tf.hbs +72 -0
- package/templates/terraform/aws/vpc.tf.hbs +63 -0
- package/templates/terraform/azure/backend.tf.hbs +8 -0
- package/templates/terraform/azure/iam.tf.hbs +36 -0
- package/templates/terraform/azure/main.tf.hbs +199 -0
- package/templates/terraform/azure/output.tf.hbs +19 -0
- package/templates/terraform/azure/provider.tf.hbs +19 -0
- package/templates/terraform/gcp/backend.tf.hbs +6 -0
- package/templates/terraform/gcp/iam.tf.hbs +47 -0
- package/templates/terraform/gcp/main.tf.hbs +171 -0
- package/templates/terraform/gcp/output.tf.hbs +15 -0
- package/templates/terraform/gcp/provider.tf.hbs +19 -0
- package/templates/terraform/vercel-railway/main.tf.hbs +64 -0
- package/templates/terraform/vercel-railway/output.tf.hbs +11 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""NotesEditingSession model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import ForeignKey, Integer, String
|
|
8
|
+
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
|
9
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
10
|
+
|
|
11
|
+
from app.models.base import Base, TimestampMixin, UUIDMixin
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NotesEditingSession(Base, UUIDMixin, TimestampMixin):
|
|
15
|
+
"""Editing session (user + business date branch)."""
|
|
16
|
+
|
|
17
|
+
__tablename__ = "notes_editing_sessions"
|
|
18
|
+
|
|
19
|
+
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
20
|
+
UUID(as_uuid=True),
|
|
21
|
+
ForeignKey("notes_users.id", ondelete="CASCADE"),
|
|
22
|
+
nullable=False,
|
|
23
|
+
)
|
|
24
|
+
branch_name: Mapped[str] = mapped_column(String(300), nullable=False)
|
|
25
|
+
pr_number: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
26
|
+
pr_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
27
|
+
commit_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
28
|
+
business_date: Mapped[str] = mapped_column(String(10), nullable=False)
|
|
29
|
+
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
|
|
30
|
+
changed_files: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
|
31
|
+
|
|
32
|
+
user: Mapped["NotesUser"] = relationship("NotesUser", back_populates="sessions")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""NotesSetting model."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import String
|
|
4
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
5
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
6
|
+
|
|
7
|
+
from app.models.base import Base
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NotesSetting(Base):
|
|
11
|
+
"""Key-value settings (KVS)."""
|
|
12
|
+
|
|
13
|
+
__tablename__ = "notes_settings"
|
|
14
|
+
|
|
15
|
+
key: Mapped[str] = mapped_column(String(100), primary_key=True)
|
|
16
|
+
value: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""NotesUser model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import DateTime, Integer, String
|
|
8
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
9
|
+
|
|
10
|
+
from app.models.base import Base, TimestampMixin, UUIDMixin
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NotesUser(Base, UUIDMixin, TimestampMixin):
|
|
14
|
+
"""User for ripla Notes (admin/editor/viewer)."""
|
|
15
|
+
|
|
16
|
+
__tablename__ = "notes_users"
|
|
17
|
+
|
|
18
|
+
username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
|
19
|
+
display_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
|
20
|
+
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
21
|
+
role: Mapped[str] = mapped_column(String(20), nullable=False, default="viewer")
|
|
22
|
+
refresh_token: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
23
|
+
failed_login_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
24
|
+
locked_until: Mapped[datetime | None] = mapped_column(
|
|
25
|
+
DateTime(timezone=True),
|
|
26
|
+
nullable=True,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
sessions: Mapped[list["NotesEditingSession"]] = relationship(
|
|
30
|
+
"NotesEditingSession",
|
|
31
|
+
back_populates="user",
|
|
32
|
+
cascade="all, delete-orphan",
|
|
33
|
+
)
|
|
34
|
+
audit_logs: Mapped[list["NotesAuditLog"]] = relationship(
|
|
35
|
+
"NotesAuditLog",
|
|
36
|
+
back_populates="user",
|
|
37
|
+
foreign_keys="NotesAuditLog.user_id",
|
|
38
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""API routers."""
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Auth router: login, refresh, logout, me, change-password."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
|
7
|
+
from sqlalchemy import select
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
|
|
10
|
+
from app.config import settings
|
|
11
|
+
from app.database import get_db
|
|
12
|
+
from app.models import NotesSetting, NotesUser
|
|
13
|
+
from app.schemas.auth import ChangePasswordRequest, LoginRequest, TokenResponse, UserInfo
|
|
14
|
+
from app.security import auth_dependency, create_access_token, hash_password, verify_password
|
|
15
|
+
from app.services.audit import audit_log
|
|
16
|
+
|
|
17
|
+
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def _get_setting(db: AsyncSession, key: str, fallback: int | str) -> int | str:
|
|
21
|
+
"""Get setting value by key."""
|
|
22
|
+
result = await db.execute(select(NotesSetting).where(NotesSetting.key == key))
|
|
23
|
+
row = result.scalar_one_or_none()
|
|
24
|
+
if row is None:
|
|
25
|
+
return fallback
|
|
26
|
+
return row.value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@router.post("/login", response_model=TokenResponse)
|
|
30
|
+
async def login(
|
|
31
|
+
request: Request,
|
|
32
|
+
body: LoginRequest,
|
|
33
|
+
db: AsyncSession = Depends(get_db),
|
|
34
|
+
):
|
|
35
|
+
"""Login with username/password. Returns JWT + sets refresh cookie."""
|
|
36
|
+
ip = request.client.host if request.client else None
|
|
37
|
+
if request.headers.get("x-forwarded-for"):
|
|
38
|
+
ip = request.headers.get("x-forwarded-for").split(",")[0].strip()
|
|
39
|
+
|
|
40
|
+
if not body.username or not body.password:
|
|
41
|
+
raise HTTPException(status_code=400, detail="username and password are required")
|
|
42
|
+
|
|
43
|
+
result = await db.execute(select(NotesUser).where(NotesUser.username == body.username))
|
|
44
|
+
user = result.scalar_one_or_none()
|
|
45
|
+
if user is None:
|
|
46
|
+
await audit_log(db, None, "login_failed", ip, {"username": body.username, "reason": "user_not_found"})
|
|
47
|
+
raise HTTPException(status_code=401, detail="Invalid credentials")
|
|
48
|
+
|
|
49
|
+
if user.locked_until and user.locked_until > datetime.now(timezone.utc):
|
|
50
|
+
remaining = int((user.locked_until - datetime.now(timezone.utc)).total_seconds() / 60)
|
|
51
|
+
await audit_log(db, user.id, "login_failed", ip, {"reason": "account_locked"})
|
|
52
|
+
raise HTTPException(status_code=423, detail=f"Account locked. Try again in {remaining} minutes.")
|
|
53
|
+
|
|
54
|
+
if not verify_password(body.password, user.password_hash):
|
|
55
|
+
max_attempts = await _get_setting(db, "max_login_attempts", 5)
|
|
56
|
+
lockout_min = await _get_setting(db, "lockout_duration_min", 15)
|
|
57
|
+
user.failed_login_attempts = (user.failed_login_attempts or 0) + 1
|
|
58
|
+
if user.failed_login_attempts >= max_attempts:
|
|
59
|
+
from datetime import timedelta
|
|
60
|
+
|
|
61
|
+
user.locked_until = datetime.now(timezone.utc) + timedelta(minutes=lockout_min)
|
|
62
|
+
await db.flush()
|
|
63
|
+
await audit_log(db, user.id, "login_failed", ip, {"reason": "wrong_password", "attempts": user.failed_login_attempts})
|
|
64
|
+
raise HTTPException(status_code=401, detail="Invalid credentials")
|
|
65
|
+
|
|
66
|
+
import uuid
|
|
67
|
+
|
|
68
|
+
access_token = create_access_token(
|
|
69
|
+
str(user.id),
|
|
70
|
+
user.username,
|
|
71
|
+
user.display_name,
|
|
72
|
+
user.role,
|
|
73
|
+
)
|
|
74
|
+
refresh_token = str(uuid.uuid4())
|
|
75
|
+
user.refresh_token = refresh_token
|
|
76
|
+
user.failed_login_attempts = 0
|
|
77
|
+
user.locked_until = None
|
|
78
|
+
await db.flush()
|
|
79
|
+
|
|
80
|
+
await audit_log(db, user.id, "login", ip)
|
|
81
|
+
|
|
82
|
+
expires_at = datetime.fromtimestamp(
|
|
83
|
+
datetime.now(timezone.utc).timestamp() + settings.jwt_access_expires_min * 60,
|
|
84
|
+
tz=timezone.utc,
|
|
85
|
+
).isoformat()
|
|
86
|
+
data = TokenResponse(
|
|
87
|
+
access_token=access_token,
|
|
88
|
+
user=UserInfo(id=str(user.id), username=user.username, display_name=user.display_name, role=user.role),
|
|
89
|
+
expires_at=expires_at,
|
|
90
|
+
)
|
|
91
|
+
from fastapi.responses import JSONResponse
|
|
92
|
+
|
|
93
|
+
response = JSONResponse(content=data.model_dump(by_alias=True))
|
|
94
|
+
response.set_cookie(
|
|
95
|
+
key="refresh_token",
|
|
96
|
+
value=refresh_token,
|
|
97
|
+
httponly=True,
|
|
98
|
+
samesite="strict",
|
|
99
|
+
path="/api/auth",
|
|
100
|
+
max_age=settings.jwt_refresh_expires_days * 86400,
|
|
101
|
+
)
|
|
102
|
+
return response
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@router.post("/refresh", response_model=TokenResponse)
|
|
106
|
+
async def refresh(
|
|
107
|
+
request: Request,
|
|
108
|
+
db: AsyncSession = Depends(get_db),
|
|
109
|
+
):
|
|
110
|
+
"""Refresh access token using refresh_token cookie."""
|
|
111
|
+
cookie = request.headers.get("cookie", "")
|
|
112
|
+
refresh_token = None
|
|
113
|
+
for part in cookie.split(";"):
|
|
114
|
+
part = part.strip()
|
|
115
|
+
if part.startswith("refresh_token="):
|
|
116
|
+
refresh_token = part.split("=", 1)[1].strip()
|
|
117
|
+
break
|
|
118
|
+
if not refresh_token:
|
|
119
|
+
raise HTTPException(status_code=401, detail="Refresh token required")
|
|
120
|
+
|
|
121
|
+
result = await db.execute(select(NotesUser).where(NotesUser.refresh_token == refresh_token))
|
|
122
|
+
user = result.scalar_one_or_none()
|
|
123
|
+
if user is None:
|
|
124
|
+
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
|
125
|
+
|
|
126
|
+
access_token = create_access_token(str(user.id), user.username, user.display_name, user.role)
|
|
127
|
+
expires_at = datetime.fromtimestamp(
|
|
128
|
+
datetime.now(timezone.utc).timestamp() + settings.jwt_access_expires_min * 60,
|
|
129
|
+
tz=timezone.utc,
|
|
130
|
+
).isoformat()
|
|
131
|
+
data = TokenResponse(
|
|
132
|
+
access_token=access_token,
|
|
133
|
+
user=UserInfo(id=str(user.id), username=user.username, display_name=user.display_name, role=user.role),
|
|
134
|
+
expires_at=expires_at,
|
|
135
|
+
)
|
|
136
|
+
from fastapi.responses import JSONResponse
|
|
137
|
+
|
|
138
|
+
return JSONResponse(content=data.model_dump(by_alias=True))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@router.post("/logout")
|
|
142
|
+
async def logout(request: Request, db: AsyncSession = Depends(get_db)):
|
|
143
|
+
"""Logout: clear refresh token and cookie."""
|
|
144
|
+
cookie = request.headers.get("cookie", "")
|
|
145
|
+
for part in cookie.split(";"):
|
|
146
|
+
part = part.strip()
|
|
147
|
+
if part.startswith("refresh_token="):
|
|
148
|
+
token = part.split("=", 1)[1].strip()
|
|
149
|
+
result = await db.execute(select(NotesUser).where(NotesUser.refresh_token == token))
|
|
150
|
+
user = result.scalar_one_or_none()
|
|
151
|
+
if user:
|
|
152
|
+
user.refresh_token = None
|
|
153
|
+
await db.flush()
|
|
154
|
+
break
|
|
155
|
+
|
|
156
|
+
from fastapi.responses import JSONResponse
|
|
157
|
+
|
|
158
|
+
response = JSONResponse(content={"success": True})
|
|
159
|
+
response.delete_cookie(key="refresh_token", path="/api/auth")
|
|
160
|
+
return response
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@router.get("/me")
|
|
164
|
+
async def me(
|
|
165
|
+
payload: dict = Depends(auth_dependency),
|
|
166
|
+
db: AsyncSession = Depends(get_db),
|
|
167
|
+
):
|
|
168
|
+
"""Get current user info."""
|
|
169
|
+
result = await db.execute(select(NotesUser).where(NotesUser.id == UUID(payload["sub"])))
|
|
170
|
+
user = result.scalar_one_or_none()
|
|
171
|
+
if user is None:
|
|
172
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
173
|
+
return {
|
|
174
|
+
"id": str(user.id),
|
|
175
|
+
"username": user.username,
|
|
176
|
+
"displayName": user.display_name,
|
|
177
|
+
"role": user.role,
|
|
178
|
+
"createdAt": user.created_at.isoformat() if user.created_at else None,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@router.post("/change-password")
|
|
183
|
+
async def change_password(
|
|
184
|
+
request: Request,
|
|
185
|
+
body: ChangePasswordRequest,
|
|
186
|
+
payload: dict = Depends(auth_dependency),
|
|
187
|
+
db: AsyncSession = Depends(get_db),
|
|
188
|
+
):
|
|
189
|
+
"""Change password for current user."""
|
|
190
|
+
if not body.current_password or not body.new_password:
|
|
191
|
+
raise HTTPException(status_code=400, detail="currentPassword and newPassword are required")
|
|
192
|
+
|
|
193
|
+
result = await db.execute(select(NotesUser).where(NotesUser.id == UUID(payload["sub"])))
|
|
194
|
+
user = result.scalar_one_or_none()
|
|
195
|
+
if user is None:
|
|
196
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
197
|
+
if not verify_password(body.current_password, user.password_hash):
|
|
198
|
+
raise HTTPException(status_code=401, detail="Current password is incorrect")
|
|
199
|
+
|
|
200
|
+
user.password_hash = hash_password(body.new_password)
|
|
201
|
+
await db.flush()
|
|
202
|
+
|
|
203
|
+
ip = request.client.host if request.client else None
|
|
204
|
+
await audit_log(db, user.id, "password_changed", ip)
|
|
205
|
+
return {"success": True}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Files router: get/put file content, history."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
|
|
7
|
+
from app.config import settings
|
|
8
|
+
from app.database import get_db
|
|
9
|
+
from app.security import auth_dependency, require_role
|
|
10
|
+
from app.services.github import fetch_file_commits, fetch_file_content, get_github_config, get_mock_file_content
|
|
11
|
+
from app.services.pr_chain import save_to_working_branch
|
|
12
|
+
|
|
13
|
+
router = APIRouter(prefix="/api", tags=["files"])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FileUpdateBody(BaseModel):
|
|
17
|
+
"""File update request body."""
|
|
18
|
+
|
|
19
|
+
content: str
|
|
20
|
+
sha: str | None = None
|
|
21
|
+
message: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.get("/files/{file_path:path}")
|
|
25
|
+
async def get_file(file_path: str):
|
|
26
|
+
"""Get file content. Mock mode when GITHUB_TOKEN not set."""
|
|
27
|
+
if not file_path:
|
|
28
|
+
raise HTTPException(status_code=400, detail="File path is required")
|
|
29
|
+
decoded = file_path # Already decoded by FastAPI
|
|
30
|
+
try:
|
|
31
|
+
if settings.is_mock_mode():
|
|
32
|
+
return get_mock_file_content(decoded)
|
|
33
|
+
config = get_github_config()
|
|
34
|
+
return await fetch_file_content(decoded, config)
|
|
35
|
+
except Exception as e:
|
|
36
|
+
msg = str(e)
|
|
37
|
+
if "404" in msg:
|
|
38
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
39
|
+
raise HTTPException(status_code=500, detail=msg)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.get("/history/{file_path:path}")
|
|
43
|
+
async def get_history(file_path: str):
|
|
44
|
+
"""Get file commit history."""
|
|
45
|
+
if not file_path:
|
|
46
|
+
raise HTTPException(status_code=400, detail="File path is required")
|
|
47
|
+
if settings.is_mock_mode():
|
|
48
|
+
return []
|
|
49
|
+
try:
|
|
50
|
+
config = get_github_config()
|
|
51
|
+
return await fetch_file_commits(file_path, config)
|
|
52
|
+
except Exception as e:
|
|
53
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@router.put("/files/{file_path:path}")
|
|
57
|
+
async def put_file(
|
|
58
|
+
file_path: str,
|
|
59
|
+
body: FileUpdateBody,
|
|
60
|
+
db: AsyncSession = Depends(get_db),
|
|
61
|
+
payload: dict = Depends(auth_dependency),
|
|
62
|
+
_: dict = Depends(require_role("admin", "editor")),
|
|
63
|
+
):
|
|
64
|
+
"""Save file (PR chain). Mock mode disabled."""
|
|
65
|
+
if settings.is_mock_mode():
|
|
66
|
+
raise HTTPException(status_code=400, detail="Mock mode: save disabled")
|
|
67
|
+
try:
|
|
68
|
+
config = get_github_config()
|
|
69
|
+
user_info = {"id": str(payload["sub"]), "username": payload["username"], "displayName": payload.get("displayName", payload["username"])}
|
|
70
|
+
message = body.message or f"docs: update {file_path.split('/')[-1]}"
|
|
71
|
+
result = await save_to_working_branch(
|
|
72
|
+
db, file_path, body.content, message, body.sha, user_info, config
|
|
73
|
+
)
|
|
74
|
+
return result
|
|
75
|
+
except Exception as e:
|
|
76
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""PR router: sessions, create, discard."""
|
|
2
|
+
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
|
|
8
|
+
from app.config import settings
|
|
9
|
+
from app.database import get_db
|
|
10
|
+
from app.security import auth_dependency, require_role
|
|
11
|
+
from app.services.github import get_github_config
|
|
12
|
+
from app.services.github_pr import find_open_auto_prs
|
|
13
|
+
from app.services.pr_chain import create_ready_pr, discard_changes, get_all_active_sessions, get_session_for_user
|
|
14
|
+
|
|
15
|
+
router = APIRouter(prefix="/api/pr", tags=["pr"], dependencies=[Depends(auth_dependency)])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.get("/sessions")
|
|
19
|
+
async def list_sessions(db: AsyncSession = Depends(get_db)):
|
|
20
|
+
"""Get all active sessions."""
|
|
21
|
+
if settings.is_mock_mode():
|
|
22
|
+
return {"sessions": [], "mock": True}
|
|
23
|
+
sessions = await get_all_active_sessions(db)
|
|
24
|
+
return {"sessions": sessions}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get("/my-session")
|
|
28
|
+
async def my_session(
|
|
29
|
+
payload: dict = Depends(auth_dependency),
|
|
30
|
+
db: AsyncSession = Depends(get_db),
|
|
31
|
+
):
|
|
32
|
+
"""Get current user's session."""
|
|
33
|
+
if settings.is_mock_mode():
|
|
34
|
+
return {"session": None, "mock": True}
|
|
35
|
+
session = await get_session_for_user(db, UUID(payload["sub"]))
|
|
36
|
+
return {"session": session}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.get("/latest")
|
|
40
|
+
async def latest_pr(
|
|
41
|
+
payload: dict = Depends(auth_dependency),
|
|
42
|
+
):
|
|
43
|
+
"""Get latest open auto PR."""
|
|
44
|
+
if settings.is_mock_mode():
|
|
45
|
+
return {"pr": None, "mock": True}
|
|
46
|
+
try:
|
|
47
|
+
config = get_github_config()
|
|
48
|
+
prs = await find_open_auto_prs(config)
|
|
49
|
+
if not prs:
|
|
50
|
+
return {"pr": None}
|
|
51
|
+
prs.sort(key=lambda p: p.get("created_at", ""), reverse=True)
|
|
52
|
+
return {"pr": prs[0]}
|
|
53
|
+
except Exception as e:
|
|
54
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@router.post("/create")
|
|
58
|
+
async def create_pr_route(
|
|
59
|
+
payload: dict = Depends(auth_dependency),
|
|
60
|
+
db: AsyncSession = Depends(get_db),
|
|
61
|
+
_: dict = Depends(require_role("admin", "editor")),
|
|
62
|
+
):
|
|
63
|
+
"""Create PR from current user's branch."""
|
|
64
|
+
if settings.is_mock_mode():
|
|
65
|
+
raise HTTPException(status_code=400, detail="Mock mode: PR creation disabled")
|
|
66
|
+
try:
|
|
67
|
+
config = get_github_config()
|
|
68
|
+
pr = await create_ready_pr(db, UUID(payload["sub"]), config)
|
|
69
|
+
return {"pr": pr}
|
|
70
|
+
except ValueError as e:
|
|
71
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
72
|
+
except Exception as e:
|
|
73
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@router.post("/discard")
|
|
77
|
+
async def discard_pr(
|
|
78
|
+
payload: dict = Depends(auth_dependency),
|
|
79
|
+
db: AsyncSession = Depends(get_db),
|
|
80
|
+
_: dict = Depends(require_role("admin", "editor")),
|
|
81
|
+
):
|
|
82
|
+
"""Discard changes."""
|
|
83
|
+
if settings.is_mock_mode():
|
|
84
|
+
raise HTTPException(status_code=400, detail="Mock mode: discard disabled")
|
|
85
|
+
try:
|
|
86
|
+
config = get_github_config()
|
|
87
|
+
await discard_changes(db, UUID(payload["sub"]), config)
|
|
88
|
+
return {"success": True}
|
|
89
|
+
except ValueError as e:
|
|
90
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
91
|
+
except Exception as e:
|
|
92
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Settings router."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
from sqlalchemy import select
|
|
6
|
+
from sqlalchemy.dialects.postgresql import insert
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
8
|
+
|
|
9
|
+
from app.database import get_db
|
|
10
|
+
from app.models import NotesSetting
|
|
11
|
+
from app.security import auth_dependency, require_role
|
|
12
|
+
|
|
13
|
+
router = APIRouter(prefix="/api/settings", tags=["settings"], dependencies=[Depends(auth_dependency)])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.get("")
|
|
17
|
+
async def get_settings(db: AsyncSession = Depends(get_db)):
|
|
18
|
+
"""Get all settings as key-value object."""
|
|
19
|
+
result = await db.execute(select(NotesSetting))
|
|
20
|
+
rows = result.all()
|
|
21
|
+
return {row.key: row.value for row in rows}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SettingUpdate(BaseModel):
|
|
25
|
+
"""Setting update body."""
|
|
26
|
+
|
|
27
|
+
value: int | str | list | dict
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@router.put("/{key}")
|
|
31
|
+
async def update_setting(
|
|
32
|
+
key: str,
|
|
33
|
+
body: SettingUpdate,
|
|
34
|
+
db: AsyncSession = Depends(get_db),
|
|
35
|
+
_: dict = Depends(require_role("admin")),
|
|
36
|
+
):
|
|
37
|
+
"""Update setting (admin only)."""
|
|
38
|
+
stmt = insert(NotesSetting).values(key=key, value=body.value).on_conflict_do_update(
|
|
39
|
+
index_elements=["key"],
|
|
40
|
+
set_={"value": body.value},
|
|
41
|
+
)
|
|
42
|
+
await db.execute(stmt)
|
|
43
|
+
await db.flush()
|
|
44
|
+
return {"key": key, "value": body.value}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Tree router: file tree from GitHub."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException
|
|
4
|
+
|
|
5
|
+
from app.config import settings
|
|
6
|
+
from app.services.github import fetch_directory_tree, get_github_config, get_mock_tree
|
|
7
|
+
|
|
8
|
+
router = APIRouter(prefix="/api", tags=["tree"])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@router.get("/tree")
|
|
12
|
+
async def get_tree(path: str | None = None):
|
|
13
|
+
"""Get file tree. Mock mode when GITHUB_TOKEN not set."""
|
|
14
|
+
try:
|
|
15
|
+
if settings.is_mock_mode():
|
|
16
|
+
return get_mock_tree()
|
|
17
|
+
config = get_github_config()
|
|
18
|
+
query_path = path or config["docs_path"]
|
|
19
|
+
tree = await fetch_directory_tree(query_path, config)
|
|
20
|
+
return tree
|
|
21
|
+
except Exception as e:
|
|
22
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Users router (admin only)."""
|
|
2
|
+
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
6
|
+
from sqlalchemy import select
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
8
|
+
|
|
9
|
+
from app.database import get_db
|
|
10
|
+
from app.models import NotesAuditLog, NotesUser
|
|
11
|
+
from app.schemas.user import UserCreate, UserUpdate
|
|
12
|
+
from app.security import auth_dependency, hash_password, require_role
|
|
13
|
+
from app.services.audit import audit_log
|
|
14
|
+
|
|
15
|
+
router = APIRouter(prefix="/api/users", tags=["users"], dependencies=[Depends(auth_dependency), Depends(require_role("admin"))])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.get("")
|
|
19
|
+
async def list_users(db: AsyncSession = Depends(get_db)):
|
|
20
|
+
"""List all users (admin only)."""
|
|
21
|
+
result = await db.execute(
|
|
22
|
+
select(
|
|
23
|
+
NotesUser.id,
|
|
24
|
+
NotesUser.username,
|
|
25
|
+
NotesUser.display_name,
|
|
26
|
+
NotesUser.role,
|
|
27
|
+
NotesUser.failed_login_attempts,
|
|
28
|
+
NotesUser.locked_until,
|
|
29
|
+
NotesUser.created_at,
|
|
30
|
+
NotesUser.updated_at,
|
|
31
|
+
).order_by(NotesUser.created_at)
|
|
32
|
+
)
|
|
33
|
+
rows = result.all()
|
|
34
|
+
return [
|
|
35
|
+
{
|
|
36
|
+
"id": str(r.id),
|
|
37
|
+
"username": r.username,
|
|
38
|
+
"displayName": r.display_name,
|
|
39
|
+
"role": r.role,
|
|
40
|
+
"failedLoginAttempts": r.failed_login_attempts,
|
|
41
|
+
"lockedUntil": r.locked_until.isoformat() if r.locked_until else None,
|
|
42
|
+
"createdAt": r.created_at.isoformat() if r.created_at else None,
|
|
43
|
+
"updatedAt": r.updated_at.isoformat() if r.updated_at else None,
|
|
44
|
+
}
|
|
45
|
+
for r in rows
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@router.post("", status_code=201)
|
|
50
|
+
async def create_user(
|
|
51
|
+
body: UserCreate,
|
|
52
|
+
payload: dict = Depends(auth_dependency),
|
|
53
|
+
db: AsyncSession = Depends(get_db),
|
|
54
|
+
):
|
|
55
|
+
"""Create user (admin only)."""
|
|
56
|
+
if body.role not in ("admin", "editor", "viewer"):
|
|
57
|
+
raise HTTPException(status_code=400, detail="role must be admin, editor, or viewer")
|
|
58
|
+
if len(body.password) < 8:
|
|
59
|
+
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
|
60
|
+
|
|
61
|
+
result = await db.execute(select(NotesUser).where(NotesUser.username == body.username))
|
|
62
|
+
if result.scalar_one_or_none():
|
|
63
|
+
raise HTTPException(status_code=409, detail="Username already exists")
|
|
64
|
+
|
|
65
|
+
user = NotesUser(
|
|
66
|
+
username=body.username,
|
|
67
|
+
display_name=body.display_name,
|
|
68
|
+
password_hash=hash_password(body.password),
|
|
69
|
+
role=body.role,
|
|
70
|
+
)
|
|
71
|
+
db.add(user)
|
|
72
|
+
await db.flush()
|
|
73
|
+
await db.refresh(user)
|
|
74
|
+
|
|
75
|
+
await audit_log(db, UUID(payload["sub"]), "user_create", detail={"targetUser": body.username, "role": body.role})
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
"id": str(user.id),
|
|
79
|
+
"username": user.username,
|
|
80
|
+
"displayName": user.display_name,
|
|
81
|
+
"role": user.role,
|
|
82
|
+
"createdAt": user.created_at.isoformat() if user.created_at else None,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@router.put("/{user_id}")
|
|
87
|
+
async def update_user(
|
|
88
|
+
user_id: str,
|
|
89
|
+
body: UserUpdate,
|
|
90
|
+
payload: dict = Depends(auth_dependency),
|
|
91
|
+
db: AsyncSession = Depends(get_db),
|
|
92
|
+
):
|
|
93
|
+
"""Update user (admin only)."""
|
|
94
|
+
result = await db.execute(select(NotesUser).where(NotesUser.id == UUID(user_id)))
|
|
95
|
+
user = result.scalar_one_or_none()
|
|
96
|
+
if user is None:
|
|
97
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
98
|
+
|
|
99
|
+
if body.display_name is not None:
|
|
100
|
+
user.display_name = body.display_name
|
|
101
|
+
if body.role is not None:
|
|
102
|
+
if body.role not in ("admin", "editor", "viewer"):
|
|
103
|
+
raise HTTPException(status_code=400, detail="role must be admin, editor, or viewer")
|
|
104
|
+
user.role = body.role
|
|
105
|
+
if body.password is not None:
|
|
106
|
+
if len(body.password) < 8:
|
|
107
|
+
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
|
108
|
+
user.password_hash = hash_password(body.password)
|
|
109
|
+
|
|
110
|
+
await db.flush()
|
|
111
|
+
await db.refresh(user)
|
|
112
|
+
|
|
113
|
+
await audit_log(db, UUID(payload["sub"]), "user_update", detail={"targetUserId": user_id, "fields": list(body.model_dump(exclude_none=True).keys())})
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
"id": str(user.id),
|
|
117
|
+
"username": user.username,
|
|
118
|
+
"displayName": user.display_name,
|
|
119
|
+
"role": user.role,
|
|
120
|
+
"createdAt": user.created_at.isoformat() if user.created_at else None,
|
|
121
|
+
"updatedAt": user.updated_at.isoformat() if user.updated_at else None,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@router.delete("/{user_id}")
|
|
126
|
+
async def delete_user(
|
|
127
|
+
user_id: str,
|
|
128
|
+
payload: dict = Depends(auth_dependency),
|
|
129
|
+
db: AsyncSession = Depends(get_db),
|
|
130
|
+
):
|
|
131
|
+
"""Delete user (admin only)."""
|
|
132
|
+
if user_id == str(payload["sub"]):
|
|
133
|
+
raise HTTPException(status_code=400, detail="Cannot delete your own account")
|
|
134
|
+
|
|
135
|
+
result = await db.execute(select(NotesUser).where(NotesUser.id == UUID(user_id)))
|
|
136
|
+
user = result.scalar_one_or_none()
|
|
137
|
+
if user is None:
|
|
138
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
139
|
+
|
|
140
|
+
username = user.username
|
|
141
|
+
await db.delete(user)
|
|
142
|
+
await db.flush()
|
|
143
|
+
|
|
144
|
+
await audit_log(db, UUID(payload["sub"]), "user_delete", detail={"targetUser": username})
|
|
145
|
+
return {"success": True}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Pydantic schemas."""
|