diff --git a/README.md b/README.md index bd87aab..58941cb 100644 --- a/README.md +++ b/README.md @@ -6,21 +6,23 @@ and gives actionable mitigation plans — without the AWS DevOps Agent price tag ## What's inside - **LangChain DeepAgents** as the agent framework — planning, tool orchestration, and session memory out of the box -- **19 read-only AWS tools** across CloudWatch, CloudTrail, ECS, Lambda, EC2, RDS, and IAM — plain Python functions, schemas inferred automatically +- **21 read-only AWS tools** across CloudWatch (6), CloudTrail (2), ECS (4), Lambda (4), EC2 (2), RDS (2), IAM (1), plus bash escape hatch, cross-session history analytics, skills, and `submit_investigation` — plain Python functions, schemas inferred automatically - **Sandboxed bash execution tool** — agent can run whitelisted read-only AWS CLI, kubectl, and docker commands as a last resort when the structured tools fall short; every command validated against an allowlist before execution; never uses `shell=True`; hard 30-second timeout - Includes **CloudWatch Logs Insights** (`query_logs_insights`) — full query language support: `fields`, `filter`, `stats`, `sort`, `limit`; results include scanned MB - **Streaming responses** — FastAPI SSE endpoint streams agent tokens in real time as the LLM reasons; tool calls appear as they complete -- **Web UI** — FastAPI backend with a chat interface that shows: +- **Web UI** — React + Vite SPA served by FastAPI: + - **Chat page** — streaming responses, collapsible tool call inspector, cost/latency card, stop button - **Session history sidebar** — lists all past conversations; click any to resume with full tool call inspector and cost card restored; new chat and delete (soft) buttons - - Live tool calls (name, args, result) — collapsible, closed by default - - **Cost tracking card** — input/output tokens, per-component USD cost, total cost, latency — collapsible, closed by default - - Pricing map for `google/gemma-4-26b-a4b-it`, `anthropic/claude-3.5-sonnet`, `openai/gpt-4o` (extend as needed) - - Stop button cancels an in-flight request mid-stream + - **Dashboard** — session counts, tool call stats, cost/latency, context saved, activity chart, service breakdown, root cause distribution, recent sessions + - **History page** — keyword search across all past sessions + - **Settings page** — real-time read-only view of active configuration (env vars + agent config) fetched from the backend + - **Team page** — admin-only user management: add, remove, and change roles +- **Auth & RBAC** — optional password-based auth with `admin` and `user` roles; JWT tokens; first registered user auto-becomes admin; disabled by default (set `JWT_SECRET` to enable) — see [docs/auth.md](docs/auth.md) - **Three storage backends** — pick one via `CHECKPOINT_BACKEND` in `.env`; see [`docs/databases.md`](docs/databases.md) - `memory` — zero config, no persistence; great for CI and quick testing - `sqlite` — local file, no external services; recommended for single-server and personal use - `postgres` — full production persistence via psycopg3 + `AsyncPostgresSaver` - - Schema: `sessions`, `messages`, `tool_calls`, `usage_events` — see [`docs/schema.md`](docs/schema.md) + - Schema: `users`, `sessions`, `messages`, `tool_calls`, `usage_events` — see [`docs/schema.md`](docs/schema.md) - Soft delete — deleted sessions are hidden immediately but data is preserved for the 30-day cleanup job - **Structured logging** via Loguru — used consistently across all modules (tools, agent, API, CLI); every request shows agent reasoning, tool calls with args/results, and a done summary with latency + token counts - **CLI** — `devops-agent investigate`, `ask`, and `report` commands powered by the same agent @@ -155,20 +157,32 @@ src/ ├── tools/ # 19 read-only AWS tool functions ├── api/ │ ├── app.py # FastAPI app factory — mounts routers, serves frontend +│ ├── auth.py # JWT helpers + FastAPI auth dependencies │ └── routers/ +│ ├── auth.py # POST /auth/register|login · GET /auth/status|me │ ├── chat.py # POST /chat — SSE streaming endpoint -│ └── sessions.py# GET/DELETE /sessions — session history +│ ├── sessions.py# GET/DELETE /sessions — session history +│ ├── users.py # GET/POST/PATCH/DELETE /users (admin only) +│ ├── settings.py# GET /settings — read-only config view +│ ├── history.py # GET /history/* — cross-session analytics +│ └── dashboard.py# GET /stats ├── cli/ # Typer CLI commands -└── integrations/ # Future: Slack, PagerDuty +├── config/ +│ └── appsettings.py # Pydantic Settings — single source of truth for all env vars +├── models/ # Pydantic models: agent, chat, sessions, users +├── skills/ # Markdown runbooks (lambda-throttling + add your own) +└── integrations/ + └── slack_webhook.py frontend/ -└── src/ # React UI source (Vite) +└── src/ + ├── pages/ # ChatPage, DashboardPage, HistoryPage, SettingsPage, UsersPage, LoginPage + └── components/ # Sidebar, Header, ProtectedRoute, AgentMessage, ... migrations/ -└── 001_initial.sql # App schema (sessions, messages, tool_calls, usage_events) -scripts/ -├── setup_db.py # One-shot DB setup (runs migrations + LangGraph checkpointer) -└── test_db_connection.py # DB connectivity smoke test (.env-driven) -docs/ -└── schema.md # Full schema reference with ER diagram +├── 001_initial.sql # Base schema +├── 002_soft_delete.sql +├── 003_usage_events_metadata.sql +└── 004_users_rbac.sql # password_hash + role on users +docs/ # Feature reference — auth, schema, skills, databases, UI, ... ``` ## Configuration @@ -191,6 +205,11 @@ docs/ | `POLL_INTERVAL_MINUTES` | `0` | Proactive polling interval in minutes; `0` disables the poller | | `POLL_ERROR_THRESHOLD` | `5.0` | Lambda error rate % that triggers an automatic investigation | | `POLL_REINVESTIGATE_HOURS` | `1` | Cooldown period — skip re-investigating the same alarm within N hours | +| `SUMMARIZATION_ENABLED` | `true` | Auto-compact sessions when they exceed the threshold | +| `SUMMARIZATION_THRESHOLD_CHARS` | `60000` | Total session chars before compaction fires (~15K tokens) | +| `SUMMARIZATION_KEEP_CHARS` | `20000` | Recent chars to preserve intact during compaction (~5K tokens) | +| `JWT_SECRET` | none | Secret key for JWT signing; leave unset to disable auth entirely | +| `JWT_EXPIRE_MINUTES` | `1440` | JWT token lifetime in minutes (default 24 h) | ## TODO / Roadmap @@ -199,7 +218,7 @@ docs/ - [x] **Schema / models layer** — centralized `src/models/` package for all Pydantic models: agent domain, memory state, and API request/response schemas - [ ] **Soft-deleted session cleanup job** — product version only; OSS users manage their own DB - [x] **Investigation history skill** — cross-session analysis: recurring errors, most-triggered alarms, patterns across all past sessions for a user -- [ ] **User roles** — `superadmin`, `admin`, `user`; role-based access to features and dashboards +- [x] **User roles** — `admin` / `user` roles with JWT auth, first-user bootstrap, admin-only user management UI; optional (disabled when `JWT_SECRET` unset) — see [docs/auth.md](docs/auth.md) ### Medium-term - [x] **React frontend** — rewrite the single-file HTML UI in React; component-based architecture, proper state management, hot reload @@ -233,7 +252,7 @@ docs/ ### Product (SaaS) - [ ] **Redis cache** — replace in-process `cachetools` with Redis; shared across workers, survives restarts, per-org cache namespacing to prevent data leakage between tenants - [ ] **Soft-deleted session cleanup** — scheduled job (Inngest or APScheduler) to purge `is_deleted = TRUE` sessions older than a configurable retention window (default 30 days); GDPR right-to-erasure compliance -- [ ] **Auth & user roles** — `superadmin`, `admin`, `user`; JWT-based auth, role-based access control, org-scoped AWS credential management +- [ ] **Org-scoped AWS credential management** — per-org credential vault; agents use org-scoped profiles instead of a single global `AWS_PROFILE` - [ ] **Per-org AWS credential store** — encrypted credential vault per organization; agents use org-scoped profiles instead of a single global `AWS_PROFILE` - [ ] **Billing & usage metering** — track token usage and tool calls per org/user; expose cost dashboards; integrate with Stripe for usage-based billing diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 0000000..783221b --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,100 @@ +# Authentication & RBAC + +OpenDevOps supports optional password-based authentication with two roles: `admin` and `user`. Auth is disabled by default — set `JWT_SECRET` to enable it. + +--- + +## Enabling auth + +Add to your `.env`: + +```bash +JWT_SECRET=your-secret-key-here # any long random string +JWT_EXPIRE_MINUTES=1440 # optional, default 24h +``` + +Restart the server. The login page will appear on next browser load. + +**Without `JWT_SECRET`:** the app runs in dev/open mode — no login required, all users treated as admin. Suitable for local development or single-user installs on a trusted network. + +--- + +## First-time setup + +The first user to register automatically gets the `admin` role. Subsequent registrations default to `user`. + +> **Important for users upgrading from a pre-RBAC version:** if your database already had rows in the `users` table before running migration `004_users_rbac.sql`, those existing rows don't have passwords. The "first user" check (`count_users`) only counts rows with a `password_hash`, so the first person to register after the migration will correctly get admin. + +--- + +## Roles + +| Role | Can do | +|---|---| +| `admin` | Everything — chat, history, dashboard, settings, manage users | +| `user` | Chat, history, dashboard, settings — **cannot** access `/users` (Team page) | + +Auth bypass (no `JWT_SECRET`): all requests are treated as admin regardless of role. + +--- + +## API endpoints + +### `GET /auth/status` +Returns whether auth is required. Called by the frontend on load. + +```json +{ "required": true } +``` + +### `POST /auth/register` +Registers a new user. Body: `{ email, name, password }`. Returns a JWT. + +The first user with a `password_hash` becomes `admin`; all subsequent registrations get `role = "user"`. + +### `POST /auth/login` +Body: `{ email, password }`. Returns a JWT on success, `401` on bad credentials. + +### `GET /auth/me` +Requires `Authorization: Bearer `. Returns the current user's profile. + +```json +{ "id": "uuid", "role": "admin", "name": "Ahmad Hammad", "auth_enabled": true } +``` + +--- + +## User management (admin only) + +| Endpoint | Description | +|---|---| +| `GET /users` | List all users | +| `POST /users` | Create a user (`email`, `name`, `password`, `role`) | +| `PATCH /users/{id}` | Update name, role, or password | +| `DELETE /users/{id}` | Delete a user | + +Non-admins receive `403 Forbidden` on all `/users` endpoints. + +In the UI: navigate to **Team** in the sidebar (visible to admins only). + +--- + +## JWT implementation + +- Tokens are signed with `HS256` using `JWT_SECRET` +- Payload: `{ sub: user_id, role, exp }` +- Stored in `localStorage` as `auth-token` +- Sent as `Authorization: Bearer ` on every API request +- Expiry defaults to 24 hours (`JWT_EXPIRE_MINUTES=1440`) + +--- + +## Migration + +Run `migrations/004_users_rbac.sql` on your database: + +```bash +psql $DATABASE_URL -f migrations/004_users_rbac.sql +``` + +This adds `password_hash` and `role` columns to `users`, and drops the unused `owner_key` column from `sessions`. Safe to re-run. diff --git a/docs/schema.md b/docs/schema.md index e5c3038..2b75a31 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -1,6 +1,6 @@ # Database Schema -PostgreSQL 13+. Run `migrations/001_initial.sql` to create all tables. +PostgreSQL 13+. Run migrations in order from `migrations/` to set up the schema. LangGraph's own tables (`checkpoints`, `checkpoint_blobs`, `checkpoint_writes`) are created automatically by `AsyncPostgresSaver.setup()` on startup — they live in the same @@ -8,55 +8,45 @@ database but are not listed here. --- -## Tables +## Running migrations -### `organizations` -Top-level tenant. A single-user install can have one default org. +```bash +psql $DATABASE_URL -f migrations/001_initial.sql +psql $DATABASE_URL -f migrations/002_soft_delete.sql +psql $DATABASE_URL -f migrations/003_usage_events_metadata.sql +psql $DATABASE_URL -f migrations/004_users_rbac.sql +``` -| Column | Type | Notes | -|---|---|---| -| `id` | UUID PK | `gen_random_uuid()` | -| `name` | TEXT | Display name | -| `slug` | TEXT UNIQUE | URL-safe identifier | -| `created_at` | TIMESTAMPTZ | | -| `updated_at` | TIMESTAMPTZ | | +All migrations are idempotent (`IF NOT EXISTS`, `IF column does not already exist`). --- +## Tables + ### `users` -User accounts, scoped to an org. +User accounts with password-based auth and RBAC roles. | Column | Type | Notes | |---|---|---| -| `id` | UUID PK | | -| `org_id` | UUID FK → organizations | Cascade delete | +| `id` | UUID PK | `gen_random_uuid()` | +| `org_id` | UUID FK → organizations | Nullable — unused in OSS single-tenant | | `email` | TEXT UNIQUE | | | `name` | TEXT | | +| `password_hash` | TEXT | bcrypt hash. NULL for pre-RBAC rows | +| `role` | TEXT | `admin` or `user`. Default `user` | | `created_at` | TIMESTAMPTZ | | | `updated_at` | TIMESTAMPTZ | | ---- +**Constraint:** `role IN ('admin', 'user')` -### `aws_profiles` -Named AWS connection configs per org. Enables multi-account support (Phase 3). - -| Column | Type | Notes | -|---|---|---| -| `id` | UUID PK | | -| `org_id` | UUID FK → organizations | | -| `name` | TEXT | Unique per org | -| `aws_region` | TEXT | Default `us-east-1` | -| `aws_profile` | TEXT | Named profile in `~/.aws/credentials` | -| `description` | TEXT | | -| `created_at` | TIMESTAMPTZ | | -| `updated_at` | TIMESTAMPTZ | | +**First registered user** (first row with a `password_hash`) automatically gets `role = 'admin'`. --- ### `sessions` One session = one LangGraph `thread_id`. The `id` column **is** the `thread_id` passed to LangGraph. -`user_id` and `org_id` are nullable so the app works before auth is wired up. +`user_id` and `org_id` are nullable so the app works with auth disabled. | Column | Type | Notes | |---|---|---| @@ -65,12 +55,14 @@ One session = one LangGraph `thread_id`. The `id` column **is** the `thread_id` | `org_id` | UUID FK → organizations | Nullable | | `aws_profile_id` | UUID FK → aws_profiles | Nullable | | `title` | TEXT | Auto-set from first 80 chars of first user message | -| `model` | TEXT | OpenRouter model ID used | +| `model` | TEXT | LiteLLM model ID used | | `aws_region` | TEXT | AWS region at time of session | +| `is_deleted` | BOOLEAN | Soft delete — `false` by default | +| `deleted_at` | TIMESTAMPTZ | Set when soft-deleted | | `created_at` | TIMESTAMPTZ | | | `last_active_at` | TIMESTAMPTZ | Updated on every agent turn | -**Indexes:** `user_id`, `org_id`, `last_active_at DESC` +**Indexes:** `user_id`, `org_id`, `last_active_at DESC`, `is_deleted` (partial, where false) --- @@ -83,7 +75,7 @@ Every user and assistant message in a session, in order. | `session_id` | UUID FK → sessions | Cascade delete | | `role` | TEXT | `user` or `assistant` | | `content` | TEXT | Full message text | -| `metadata` | JSONB | LangChain `run_id`, `tags`, `RunnableConfig` extras, any runtime context | +| `metadata` | JSONB | LangChain `run_id`, `tags`, `RunnableConfig` extras | | `created_at` | TIMESTAMPTZ | | **Indexes:** `(session_id, created_at)` @@ -91,7 +83,7 @@ Every user and assistant message in a session, in order. --- ### `tool_calls` -Every AWS tool invocation per agent turn. Multiple rows per assistant message. +Every AWS tool invocation per agent turn. | Column | Type | Notes | |---|---|---| @@ -116,71 +108,50 @@ One row per completed agent turn: token counts, cost, latency. |---|---|---| | `id` | UUID PK | | | `session_id` | UUID FK → sessions | | -| `message_id` | UUID FK → messages | Links to the assistant message | -| `model` | TEXT | OpenRouter model ID | +| `message_id` | UUID FK → messages | | +| `model` | TEXT | LiteLLM model ID | | `input_tokens` | INTEGER | | | `output_tokens` | INTEGER | | -| `cost_usd` | NUMERIC(14,8) | Computed from pricing map | +| `cost_usd` | NUMERIC(14,8) | Computed from LiteLLM pricing map | | `latency_ms` | INTEGER | Wall-clock time for the full turn | | `tool_call_count` | INTEGER | Number of tool calls in this turn | +| `metadata` | JSONB | Per-event context (e.g. `summarization: true`, `chars_removed`) | | `created_at` | TIMESTAMPTZ | | **Indexes:** `session_id`, `created_at DESC` --- -### `findings` -Structured root-cause analysis extracted from the agent's final answer (Phase 2). +### `organizations` *(future — Phase 3)* +Top-level tenant for multi-org / SaaS support. Table exists in the schema but is not used by the application in the current OSS release. -| Column | Type | Notes | -|---|---|---| -| `id` | UUID PK | | -| `session_id` | UUID FK → sessions | | -| `message_id` | UUID FK → messages | | -| `root_cause_category` | TEXT | `SYSTEM_CHANGE`, `INPUT_ANOMALY`, `RESOURCE_LIMIT`, `COMPONENT_FAILURE`, `DEPENDENCY_ISSUE`, `UNKNOWN` | -| `root_cause_summary` | TEXT | | -| `confidence` | TEXT | `HIGH`, `MEDIUM`, `LOW` | -| `services_affected` | TEXT[] | | -| `mitigation_steps` | TEXT[] | | -| `evidence` | TEXT[] | | -| `raw_json` | JSONB | Full structured JSON block from agent | -| `created_at` | TIMESTAMPTZ | | +### `aws_profiles` *(future — Phase 3)* +Per-org named AWS connection configs for multi-account support. Table exists but not yet wired up. -**Indexes:** `session_id`, `root_cause_category` - ---- +### `findings` *(future — Phase 2)* +Structured root-cause analysis rows extracted from agent final answers. Table exists but not yet populated. -### `api_keys` *(Phase 3)* -Hashed API keys for programmatic/CLI access. - -| Column | Type | Notes | -|---|---|---| -| `id` | UUID PK | | -| `org_id` | UUID FK → organizations | | -| `user_id` | UUID FK → users | Nullable | -| `name` | TEXT | Human label | -| `key_hash` | TEXT UNIQUE | bcrypt/sha256 hash — plaintext never stored | -| `last_used_at` | TIMESTAMPTZ | | -| `expires_at` | TIMESTAMPTZ | Nullable = no expiry | -| `created_at` | TIMESTAMPTZ | | +### `api_keys` *(future — Phase 3)* +Hashed API keys for programmatic access. Table exists but not yet implemented. --- ## Entity Relationship ``` -organizations - ├── users - ├── aws_profiles - └── sessions ──── messages ──── tool_calls - │ └── usage_events - └── findings +users +sessions ──── messages ──── tool_calls + └─────────────── usage_events ``` +--- + ## Key Design Decisions - `sessions.id` is the LangGraph `thread_id` — no join needed to link conversation history to app data. -- `messages.metadata JSONB` stores arbitrary LangChain runtime context (run ID, tags, RunnableConfig extras) without schema changes. -- `tool_calls.error` is a separate TEXT column (not just checking `result.error`) so you can query failed calls with a simple `WHERE error IS NOT NULL`. -- `usage_events.cost_usd` is computed by the app from the pricing map — not trusted from the API. -- `user_id` / `org_id` on sessions are nullable intentionally: the app is fully functional before any auth system is built. +- `messages.metadata JSONB` stores arbitrary LangChain runtime context without schema changes. +- `tool_calls.error` is a separate TEXT column (not just checking `result.error`) so failed calls are queryable with `WHERE error IS NOT NULL`. +- `usage_events.cost_usd` is computed by the app from the LiteLLM pricing map — not trusted from the API. +- `usage_events.metadata` tracks per-turn context like whether the turn triggered conversation summarization. +- `user_id` / `org_id` on sessions are nullable intentionally: the app works without auth (`JWT_SECRET` unset). +- `password_hash` on users is nullable: pre-RBAC rows and future OAuth users won't have one. diff --git a/docs/ui.md b/docs/ui.md index b65cb00..07f185f 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -70,16 +70,45 @@ See [`dashboard.md`](dashboard.md) for full details on each panel. ## Settings page -Currently shows the active configuration read from the backend: +Shows the active configuration fetched in real time from `GET /settings`. Three tabs: -- Active LLM model -- Storage backend type (memory / sqlite / postgres) -- AWS region -- Tool response cap limit -- Summarization settings +**Environment** — all env vars with their live values. Sensitive fields (`LLM_API_KEY`, +`DATABASE_URL`, `JWT_SECRET`, etc.) are masked by default; click the eye icon to reveal +the truncated preview. Non-sensitive fields (`LLM_MODEL`, `AWS_REGION`, etc.) are shown +as-is. -Settings are read-only in the UI — change them by editing `.env` and restarting -the server. +**Agent config** — agent behaviour settings: max tool calls, timeout, tool response cap, +summarization threshold, poll interval. + +**Integrations** — Slack, GitHub, PagerDuty, Datadog connection stubs (not yet wired up). + +Settings are read-only — change values by editing `.env` and restarting the server. + +--- + +## Login page + +Shown when `JWT_SECRET` is set in `.env`. Accessible at `/login`. + +- **Register tab** — create the first account (auto-admin) or subsequent accounts +- **Login tab** — email + password; stores the JWT in `localStorage` on success + +If auth is disabled (`JWT_SECRET` unset), the login page is skipped entirely and all +users get full access. + +--- + +## Team page + +Admin-only. Accessible from the sidebar (hidden for `user` role). Shows all registered +users in a table with name, email, role, and created date. + +- **Add user** — create a new user with email, name, password, and role +- **Change role** — inline dropdown to promote/demote between `admin` and `user` +- **Delete user** — removes the account immediately + +Non-admins who navigate to `/users` see an "Admin access required" message instead of +the table. --- diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cc94fe9..7c20000 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,9 +6,26 @@ import ChatPage from './pages/ChatPage'; import DashboardPage from './pages/DashboardPage'; import HistoryPage from './pages/HistoryPage'; import SettingsPage from './pages/SettingsPage'; +import UsersPage from './pages/UsersPage'; +import LoginPage from './pages/LoginPage'; +import ProtectedRoute from './components/ProtectedRoute'; import { fetchSessions, deleteSession as apiDeleteSession } from './lib/api'; +import { useAuth } from './context/AuthContext'; import type { Session } from './types'; +function LogoutPage() { + const { logout, authRequired } = useAuth(); + const [done, setDone] = useState(false); + + useEffect(() => { + logout(); + setDone(true); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + if (!done) return null; + return ; +} + function RedirectToSession() { const stored = localStorage.getItem('devops-session-id'); const id = stored ?? (() => { @@ -19,7 +36,7 @@ function RedirectToSession() { return ; } -export default function App() { +function AppLayout() { const navigate = useNavigate(); const [sessions, setSessions] = useState([]); const chatMatch = useMatch('/chat/:sessionId'); @@ -65,8 +82,23 @@ export default function App() { } /> } /> } /> + } /> ); } + +export default function App() { + return ( + + } /> + } /> + + + + } /> + + ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index d57c55f..0979c59 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,6 +1,7 @@ -import { History, Settings } from 'lucide-react'; +import { History, LogOut, Settings } from 'lucide-react'; import { Link, useMatch } from 'react-router-dom'; import { useTheme } from '../context/ThemeContext'; +import { useAuth } from '../context/AuthContext'; function DarkToggle() { const { theme, toggle } = useTheme(); @@ -25,6 +26,7 @@ function DarkToggle() { export default function Header() { const match = useMatch('/chat/:sessionId'); const sessionId = match?.params.sessionId; + const { authRequired, logout, user } = useAuth(); return (
@@ -51,6 +53,15 @@ export default function Header() { + {authRequired && ( + + )}
); diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..3885d7d --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,9 @@ +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +export default function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated, authRequired, loading } = useAuth(); + if (loading) return null; + if (authRequired && !isAuthenticated) return ; + return <>{children}; +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 332e541..3f4b651 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { Plus, X, LayoutDashboard, MessageSquare, Terminal, GitBranch, Users, Settings, LogOut } from 'lucide-react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { cn, relativeTime } from '../lib/utils'; +import { useAuth } from '../context/AuthContext'; import type { Session } from '../types'; interface Props { @@ -68,6 +69,9 @@ function NavItem({ to, matchPrefix, icon, label, badge, badgeRed, disabled }: Na export default function Sidebar({ sessions, currentSessionId, onNew, onSwitch, onDelete }: Props) { const [hovSession, setHovSession] = useState(null); const navigate = useNavigate(); + const { user, isAdmin, authRequired, logout } = useAuth(); + const displayName = user?.name || 'You'; + const displayRole = isAdmin ? 'Admin' : 'User'; const currentChatTo = currentSessionId ? `/chat/${currentSessionId}` : '/'; @@ -149,28 +153,33 @@ export default function Sidebar({ sessions, currentSessionId, onNew, onSwitch, o )} - {/* Admin section */} -
-
- Admin -
- } label="Team" /> + {/* Bottom nav */} +
+ {isAdmin && } label="Team" />} } label="Settings" />
{/* User footer */} -
navigate('/settings')} - > -
- JD -
-
-
Jane Doe
-
Admin
+
+
navigate('/settings')} + > +
+ {displayName.slice(0, 2).toUpperCase()} +
+
+
{displayName}
+
{displayRole}
+
- +
); diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..57be811 --- /dev/null +++ b/frontend/src/context/AuthContext.tsx @@ -0,0 +1,124 @@ +import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'; + +interface AuthUser { + id: string | null; + role: string; + name: string; + auth_enabled: boolean; +} + +interface AuthContextValue { + user: AuthUser | null; + token: string | null; + login: (email: string, password: string) => Promise; + logout: () => void; + register: (email: string, name: string, password: string) => Promise; + isAdmin: boolean; + isAuthenticated: boolean; + authRequired: boolean; + loading: boolean; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [token, setToken] = useState(() => localStorage.getItem('auth-token')); + const [user, setUser] = useState(null); + const [authRequired, setAuthRequired] = useState(false); + const [loading, setLoading] = useState(true); + + const fetchMe = useCallback(async (t: string | null): Promise => { + try { + const res = await fetch('/auth/me', { + headers: t ? { Authorization: `Bearer ${t}` } : {}, + }); + if (res.ok) { + setUser(await res.json() as AuthUser); + return true; + } + } catch { /* ignore */ } + return false; + }, []); + + useEffect(() => { + (async () => { + try { + const res = await fetch('/auth/status'); + if (res.ok) { + const { required } = await res.json() as { required: boolean }; + setAuthRequired(required); + if (!required) { + setUser({ id: null, role: 'admin', auth_enabled: false }); + setLoading(false); + return; + } + } + } catch { /* auth/status unavailable */ } + + if (token) { + const ok = await fetchMe(token); + if (!ok) { + localStorage.removeItem('auth-token'); + setToken(null); + } + } + setLoading(false); + })(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const login = async (email: string, password: string) => { + const res = await fetch('/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + if (!res.ok) { + const err = await res.json() as { detail?: string }; + throw new Error(err.detail ?? 'Login failed'); + } + const { access_token } = await res.json() as { access_token: string }; + localStorage.setItem('auth-token', access_token); + setToken(access_token); + await fetchMe(access_token); + }; + + const register = async (email: string, name: string, password: string) => { + const res = await fetch('/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, name, password }), + }); + if (!res.ok) { + const err = await res.json() as { detail?: string }; + throw new Error(err.detail ?? 'Registration failed'); + } + const { access_token } = await res.json() as { access_token: string }; + localStorage.setItem('auth-token', access_token); + setToken(access_token); + await fetchMe(access_token); + }; + + const logout = () => { + localStorage.removeItem('auth-token'); + setToken(null); + setUser(null); + }; + + return ( + + {children} + + ); +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be inside AuthProvider'); + return ctx; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index dde8505..91b1c65 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,30 +1,82 @@ -import type { Session, MessageRecord, HistoryStats, SearchResult } from '../types'; +import type { Session, MessageRecord, HistoryStats, SearchResult, User } from '../types'; + +export function getAuthToken(): string | null { + return localStorage.getItem('auth-token'); +} + +export async function apiFetch(url: string, options: RequestInit = {}): Promise { + const token = getAuthToken(); + const headers: Record = { + ...(options.headers as Record ?? {}), + }; + if (token) headers['Authorization'] = `Bearer ${token}`; + return fetch(url, { ...options, headers }); +} export async function fetchSessions(): Promise { - const res = await fetch('/sessions'); + const res = await apiFetch('/sessions'); if (!res.ok) throw new Error('Failed to load sessions'); return res.json() as Promise; } export async function fetchMessages(sessionId: string): Promise { - const res = await fetch(`/sessions/${sessionId}/messages`); + const res = await apiFetch(`/sessions/${sessionId}/messages`); if (!res.ok) throw new Error('Failed to load messages'); return res.json() as Promise; } export async function deleteSession(sessionId: string): Promise { - await fetch(`/sessions/${sessionId}`, { method: 'DELETE' }); + await apiFetch(`/sessions/${sessionId}`, { method: 'DELETE' }); } -export async function fetchHistory(days: number = 30): Promise { - const res = await fetch(`/history?days=${days}`); +export async function fetchHistory(days = 30): Promise { + const res = await apiFetch(`/history?days=${days}`); if (!res.ok) throw new Error('Failed to load history'); return res.json() as Promise; } export async function searchHistory(query: string): Promise { - const res = await fetch(`/history/search?q=${encodeURIComponent(query)}&limit=10`); + const res = await apiFetch(`/history/search?q=${encodeURIComponent(query)}&limit=10`); if (!res.ok) throw new Error('Failed to search history'); const data = await res.json() as { results: SearchResult[] }; return data.results; } + +// ── User management (admin only) ───────────────────────────────────────── + +export async function fetchUsers(): Promise { + const res = await apiFetch('/users'); + if (!res.ok) throw new Error('Failed to load users'); + return res.json() as Promise; +} + +export async function createUser(data: { email: string; name: string; password: string; role: string }): Promise { + const res = await apiFetch('/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!res.ok) { + const err = await res.json() as { detail?: string }; + throw new Error(err.detail ?? 'Failed to create user'); + } + return res.json() as Promise; +} + +export async function updateUser(id: string, data: { name?: string; role?: string; password?: string }): Promise { + const res = await apiFetch(`/users/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!res.ok) { + const err = await res.json() as { detail?: string }; + throw new Error(err.detail ?? 'Failed to update user'); + } + return res.json() as Promise; +} + +export async function deleteUser(id: string): Promise { + const res = await apiFetch(`/users/${id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Failed to delete user'); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 8588049..caab1db 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -5,22 +5,25 @@ import { Toaster } from 'sonner'; import './index.css'; import App from './App'; import { ThemeProvider } from './context/ThemeContext'; +import { AuthProvider } from './context/AuthContext'; createRoot(document.getElementById('root')!).render( - - + + + + , diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx index 699c729..54c9948 100644 --- a/frontend/src/pages/ChatPage.tsx +++ b/frontend/src/pages/ChatPage.tsx @@ -6,7 +6,7 @@ import EmptyState from '../components/EmptyState'; import UserMessage from '../components/UserMessage'; import AgentMessage from '../components/AgentMessage'; import InputArea from '../components/InputArea'; -import { fetchMessages } from '../lib/api'; +import { fetchMessages, getAuthToken } from '../lib/api'; import type { Message, AgentMessage as AgentMsg, MessageRecord } from '../types'; function recordsToMessages(records: MessageRecord[]): Message[] { @@ -75,9 +75,13 @@ export default function ChatPage({ onSessionsChange, onNew }: Props) { let contentAcc = ''; try { + const token = getAuthToken(); const resp = await fetch('/chat', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, body: JSON.stringify({ session_id: sessionId, message: text }), signal: ctrl.signal, }); diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..509049c --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,135 @@ +import { useState, type FormEvent } from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +type Mode = 'login' | 'register'; + +export default function LoginPage() { + const { login, register, isAuthenticated, authRequired } = useAuth(); + const [mode, setMode] = useState('login'); + const [email, setEmail] = useState(''); + const [name, setName] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + + if (!authRequired || isAuthenticated) return ; + + const submit = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + setBusy(true); + try { + if (mode === 'login') { + await login(email, password); + } else { + if (!name.trim()) { setError('Name is required'); setBusy(false); return; } + await register(email, name, password); + } + } catch (err) { + setError((err as Error).message); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ {/* Logo / title */} +
+
+ OpenDevOps +
+
+ AWS incident investigation agent +
+
+ +
+ {/* Mode tabs */} +
+ {(['login', 'register'] as Mode[]).map(m => ( + + ))} +
+ +
+ {mode === 'register' && ( +
+ + setName(e.target.value)} + placeholder="Your name" + required + className="w-full text-[13px] text-gray-900 dark:text-[#F1F5F9] bg-white dark:bg-[#18181C] border border-gray-300 dark:border-[#3F3F47] rounded-[6px] px-3 py-2 outline-none focus:border-indigo-500 dark:focus:border-[#818CF8] focus:shadow-[0_0_0_3px_rgba(99,102,241,0.12)] dark:focus:shadow-[0_0_0_3px_rgba(129,140,248,0.12)] transition-all" + /> +
+ )} +
+ + setEmail(e.target.value)} + placeholder="you@example.com" + required + className="w-full text-[13px] text-gray-900 dark:text-[#F1F5F9] bg-white dark:bg-[#18181C] border border-gray-300 dark:border-[#3F3F47] rounded-[6px] px-3 py-2 outline-none focus:border-indigo-500 dark:focus:border-[#818CF8] focus:shadow-[0_0_0_3px_rgba(99,102,241,0.12)] dark:focus:shadow-[0_0_0_3px_rgba(129,140,248,0.12)] transition-all" + /> +
+
+ + setPassword(e.target.value)} + placeholder="••••••••" + required + minLength={8} + className="w-full text-[13px] text-gray-900 dark:text-[#F1F5F9] bg-white dark:bg-[#18181C] border border-gray-300 dark:border-[#3F3F47] rounded-[6px] px-3 py-2 outline-none focus:border-indigo-500 dark:focus:border-[#818CF8] focus:shadow-[0_0_0_3px_rgba(99,102,241,0.12)] dark:focus:shadow-[0_0_0_3px_rgba(129,140,248,0.12)] transition-all" + /> +
+ + {error && ( +
+ {error} +
+ )} + + + + {mode === 'register' && ( +

+ First account created becomes the admin. +

+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index cdef351..6dacfdf 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1,64 +1,50 @@ -import { useState } from 'react'; -import { ExternalLink, Eye, EyeOff, Key, Trash2, Plus, Check } from 'lucide-react'; +import { useState, useEffect } from 'react'; +import { ExternalLink, Eye, EyeOff, Key } from 'lucide-react'; +import { apiFetch } from '../lib/api'; -const ENV_VARS = [ - { key: 'OPENROUTER_API_KEY', value: 'sk-or-v1-••••••••4f2a', secret: true }, - { key: 'OPENROUTER_MODEL', value: 'anthropic/claude-3.5-sonnet', secret: false }, - { key: 'OPENROUTER_BASE_URL', value: 'https://openrouter.ai/api/v1', secret: false }, - { key: 'AWS_REGION', value: 'us-east-1', secret: false }, - { key: 'AWS_PROFILE', value: 'devops-agent-readonly', secret: false }, - { key: 'MAX_TOOL_CALLS', value: '20', secret: false }, - { key: 'DATABASE_URL', value: 'postgres://prod.cluster.internal', secret: true }, - { key: 'LOG_LEVEL', value: 'INFO', secret: false }, -]; +interface EnvVar { key: string; value: string; secret: boolean } +interface AgentVar { key: string; label: string; value: string; hint: string } +interface SettingsData { env: EnvVar[]; agent: AgentVar[] } -const INTEGRATIONS = [ - { name: 'GitHub', desc: 'Connect your repositories', connected: false }, - { name: 'Slack', desc: 'Get incident notifications', connected: false }, - { name: 'PagerDuty', desc: 'Alert on failures', connected: false }, - { name: 'Datadog', desc: 'Send metrics and traces', connected: false }, -]; +type Tab = 'env' | 'agent' | 'integrations'; -const AGENT_FIELDS = [ - { label: 'Agent name', value: 'prod-agent-01', hint: 'Used as identifier in logs' }, - { label: 'Default region', value: 'us-east-1', hint: 'AWS region for tool calls' }, - { label: 'Max tool calls', value: '20', hint: 'Hard cap per investigation run' }, +const TABS: { id: Tab; label: string }[] = [ + { id: 'env', label: 'Environment' }, + { id: 'agent', label: 'Agent config' }, + { id: 'integrations', label: 'Integrations' }, ]; -type Tab = 'env' | 'agent' | 'integrations'; +const INTEGRATIONS = [ + { name: 'GitHub', desc: 'Connect your repositories', connected: false }, + { name: 'Slack', desc: 'Get incident notifications', connected: false }, + { name: 'PagerDuty', desc: 'Alert on failures', connected: false }, + { name: 'Datadog', desc: 'Send metrics and traces', connected: false }, +]; export default function SettingsPage() { - const [tab, setTab] = useState('env'); + const [tab, setTab] = useState('env'); const [shown, setShown] = useState>({}); - const [saved, setSaved] = useState(false); + const [data, setData] = useState(null); - const toggleShow = (k: string) => setShown(p => ({ ...p, [k]: !p[k] })); - - const handleSave = () => { - setSaved(true); - setTimeout(() => setSaved(false), 2000); - }; + useEffect(() => { + apiFetch('/settings') + .then(r => r.json()) + .then(d => setData(d as SettingsData)) + .catch(() => { /* silently ignore — show nothing */ }); + }, []); - const tabs: { id: Tab; label: string }[] = [ - { id: 'env', label: 'Environment' }, - { id: 'agent', label: 'Agent config' }, - { id: 'integrations', label: 'Integrations' }, - ]; + const toggleShow = (k: string) => setShown(p => ({ ...p, [k]: !p[k] })); return (
- {/* Page header */}
Settings
-
Manage your agent configuration and environment
+
Read-only view of your agent configuration. Edit values in your .env file and restart the server to apply changes.
- {/* Tabs */}
- {tabs.map(t => ( - -
- {/* Column headers */} -
- Key - Value -
- {ENV_VARS.map((v, i) => ( -
-
- {v.secret && } - {v.key} -
-
- {v.secret && !shown[v.key] ? '••••••••••••' : v.value} -
-
- {v.secret && ( - - )} - -
-
- ))} +
+
+ Environment Variables
-
- - +
+ Key + Value
- + {data ? data.env.map((v, i) => ( +
+
+ {v.secret && } + {v.key} +
+
+ {v.secret && !shown[v.key] ? '••••••••••••' : v.value} +
+ {v.secret && ( + + )} +
+ )) : ( +
Loading…
+ )} +
)} - {/* ── Agent config tab ── */} {tab === 'agent' && ( -
- {AGENT_FIELDS.map((f, i) => ( -
- - -
{f.hint}
+
+
+ Agent Configuration +
+ {data ? data.agent.map((f, i) => ( +
+
+
{f.label}
+
{f.hint}
+
+ {f.value}
- ))} + )) : ( +
Loading…
+ )}
)} - {/* ── Integrations tab ── */} {tab === 'integrations' && ( <>
{INTEGRATIONS.map((intg, i) => ( -
+
{intg.name === 'Slack' ? '💬' : intg.name === 'GitHub' ? '🐱' : intg.name === 'PagerDuty' ? '📟' : '📊'}
@@ -164,27 +119,15 @@ export default function SettingsPage() {
{intg.name}
{intg.desc}
- {intg.connected ? ( - - - Connected - - ) : ( - - )} +
))}
- - Browse available models on OpenRouter - + + Browse available models on OpenRouter )} diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx new file mode 100644 index 0000000..32d4985 --- /dev/null +++ b/frontend/src/pages/UsersPage.tsx @@ -0,0 +1,193 @@ +import { useState, useEffect, type FormEvent } from 'react'; +import { Trash2, Plus, UserPlus, Shield, User as UserIcon } from 'lucide-react'; +import { fetchUsers, createUser, updateUser, deleteUser } from '../lib/api'; +import { useAuth } from '../context/AuthContext'; +import type { User } from '../types'; + +function RoleBadge({ role }: { role: string }) { + return role === 'admin' ? ( + + admin + + ) : ( + + user + + ); +} + +export default function UsersPage() { + const { user: currentUser, isAdmin } = useAuth(); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [showAdd, setShowAdd] = useState(false); + const [addEmail, setAddEmail] = useState(''); + const [addName, setAddName] = useState(''); + const [addPw, setAddPw] = useState(''); + const [addRole, setAddRole] = useState<'admin' | 'user'>('user'); + const [addBusy, setAddBusy] = useState(false); + const [addError, setAddError] = useState(''); + + const load = async () => { + try { setUsers(await fetchUsers()); } catch { /* requires postgres */ } + setLoading(false); + }; + + useEffect(() => { void load(); }, []); + + const handleAdd = async (e: FormEvent) => { + e.preventDefault(); + setAddError(''); + setAddBusy(true); + try { + const u = await createUser({ email: addEmail, name: addName, password: addPw, role: addRole }); + setUsers(prev => [...prev, u]); + setShowAdd(false); + setAddEmail(''); setAddName(''); setAddPw(''); setAddRole('user'); + } catch (err) { + setAddError((err as Error).message); + } finally { + setAddBusy(false); + } + }; + + const handleRoleChange = async (id: string, role: 'admin' | 'user') => { + try { + const updated = await updateUser(id, { role }); + setUsers(prev => prev.map(u => u.id === id ? updated : u)); + } catch { /* ignore */ } + }; + + const handleDelete = async (id: string) => { + try { + await deleteUser(id); + setUsers(prev => prev.filter(u => u.id !== id)); + } catch { /* ignore */ } + }; + + if (!isAdmin) { + return ( +
+
Admin access required.
+
+ ); + } + + return ( +
+ {/* Page header */} +
+
Team
+
Manage users and roles
+
+ +
+
+ {/* Table header */} +
+ + Team members + + +
+ + {/* Column headers */} +
+ Name / Email + Role + +
+ + {loading ? ( +
Loading…
+ ) : users.length === 0 ? ( +
+ No users found. Auth requires CHECKPOINT_BACKEND=postgres and JWT_SECRET set. +
+ ) : ( + users.map((u, i) => ( +
+
+
{u.name}
+
{u.email}
+
+
+ {currentUser?.id === u.id ? ( + + ) : ( + + )} +
+
+ {currentUser?.id !== u.id && ( + + )} +
+
+ )) + )} + + {/* Add user inline form */} + {showAdd && ( +
+
Add new user
+
+
+ + setAddName(e.target.value)} required placeholder="Full name" + className="w-full text-[12px] text-gray-900 dark:text-[#F1F5F9] bg-white dark:bg-[#18181C] border border-gray-300 dark:border-[#3F3F47] rounded-[5px] px-2 py-1.5 outline-none focus:border-indigo-500 dark:focus:border-[#818CF8] transition-all" /> +
+
+ + setAddEmail(e.target.value)} required placeholder="email@example.com" + className="w-full text-[12px] text-gray-900 dark:text-[#F1F5F9] bg-white dark:bg-[#18181C] border border-gray-300 dark:border-[#3F3F47] rounded-[5px] px-2 py-1.5 outline-none focus:border-indigo-500 dark:focus:border-[#818CF8] transition-all" /> +
+
+ + setAddPw(e.target.value)} required minLength={8} placeholder="Min 8 chars" + className="w-full text-[12px] text-gray-900 dark:text-[#F1F5F9] bg-white dark:bg-[#18181C] border border-gray-300 dark:border-[#3F3F47] rounded-[5px] px-2 py-1.5 outline-none focus:border-indigo-500 dark:focus:border-[#818CF8] transition-all" /> +
+
+ + +
+
+ {addError &&
{addError}
} +
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 9202fd0..f58acea 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -81,6 +81,14 @@ export interface Session { last_active_at: string; } +export interface User { + id: string; + email: string; + name: string; + role: 'admin' | 'user'; + created_at: string; +} + export interface MessageRecord { id: string; role: 'user' | 'assistant'; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 34b7eee..0376fdf 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -14,7 +14,12 @@ export default defineConfig({ }, }, '/sessions': 'http://localhost:8000', - '/stats': 'http://localhost:8000', + '/stats': 'http://localhost:8000', + '/auth': 'http://localhost:8000', + '/users': 'http://localhost:8000', + '/settings': 'http://localhost:8000', + '/history': 'http://localhost:8000', + '/debug': 'http://localhost:8000', }, }, build: { diff --git a/migrations/004_users_rbac.sql b/migrations/004_users_rbac.sql new file mode 100644 index 0000000..dfdfbe0 --- /dev/null +++ b/migrations/004_users_rbac.sql @@ -0,0 +1,15 @@ +-- RBAC: password auth + roles for users. +-- Idempotent: safe against both fresh DBs and the partial migration. + +ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'user'; + +-- Drop and recreate constraint cleanly (handles the superadmin->admin,user change) +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check; +ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('admin', 'user')); + +CREATE INDEX IF NOT EXISTS users_role_idx ON users(role); + +-- Cleanup from experimental work (owner_key was never in the canonical schema) +ALTER TABLE sessions DROP COLUMN IF EXISTS owner_key; +DROP INDEX IF EXISTS sessions_owner_key_idx; diff --git a/pyproject.toml b/pyproject.toml index 5453279..a6c31bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,8 @@ dependencies = [ "langchain-community>=0.4.1", "langchain-litellm>=0.6.4", "fastmcp>=3.2.4", + "python-jose[cryptography]>=3.3.0", + "bcrypt>=5.0.0", ] [project.scripts] diff --git a/src/agent/db/base.py b/src/agent/db/base.py index 476d990..819334e 100644 --- a/src/agent/db/base.py +++ b/src/agent/db/base.py @@ -86,3 +86,29 @@ async def get_history_stats(self, days: int = 30) -> dict: ... @abstractmethod async def search_sessions(self, query: str, limit: int = 10) -> list[dict]: ... + + # ── User / auth ──────────────────────────────────────────────────────────── + # Default implementations return empty/zero — real auth requires PostgreSQL. + + async def count_users(self) -> int: + return 0 + + async def get_user_by_email(self, email: str) -> dict | None: + return None + + async def get_user_by_id(self, user_id: str) -> dict | None: + return None + + async def create_user( + self, email: str, name: str, password_hash: str, role: str + ) -> dict | None: + return None + + async def list_users(self) -> list[dict]: + return [] + + async def update_user(self, user_id: str, **fields: Any) -> dict | None: + return None + + async def delete_user(self, user_id: str) -> None: + pass diff --git a/src/agent/db/postgres.py b/src/agent/db/postgres.py index 41efcfa..319efe9 100644 --- a/src/agent/db/postgres.py +++ b/src/agent/db/postgres.py @@ -455,6 +455,63 @@ async def get_history_stats(self, days: int = 30) -> dict: "trend": [{"date": str(r["day"]), "count": int(r["count"])} for r in trend_rows], } + # ── User / auth ─────────────────────────────────────────────────────────── + + async def count_users(self) -> int: + row = await self._fetchrow("SELECT COUNT(*) AS n FROM users WHERE password_hash IS NOT NULL") + return int(row["n"]) if row else 0 + + async def get_user_by_email(self, email: str) -> dict | None: + return await self._fetchrow("SELECT * FROM users WHERE email = %s", email) + + async def get_user_by_id(self, user_id: str) -> dict | None: + return await self._fetchrow("SELECT * FROM users WHERE id = %s", uuid.UUID(user_id)) + + async def create_user( + self, email: str, name: str, password_hash: str, role: str + ) -> dict | None: + row = await self._fetchrow( + """ + INSERT INTO users (email, name, password_hash, role) + VALUES (%s, %s, %s, %s) + RETURNING id, email, name, role, created_at + """, + email, name, password_hash, role, + ) + if row: + row["id"] = str(row["id"]) + return row + + async def list_users(self) -> list[dict]: + rows = await self._fetchall( + "SELECT id, email, name, role, created_at FROM users ORDER BY created_at ASC" + ) + return [ + { + "id": str(r["id"]), + "email": r["email"], + "name": r["name"], + "role": r["role"], + "created_at": r["created_at"], + } + for r in rows + ] + + async def update_user(self, user_id: str, **fields: Any) -> dict | None: + sets = ", ".join(f"{k} = %s" for k in fields) + values = [*fields.values(), uuid.UUID(user_id)] + row = await self._fetchrow( + f"UPDATE users SET {sets}, updated_at = NOW() WHERE id = %s" + " RETURNING id, email, name, role, created_at", + *values, + ) + if row: + row["id"] = str(row["id"]) + return row + + async def delete_user(self, user_id: str) -> None: + await self._exec("DELETE FROM users WHERE id = %s", uuid.UUID(user_id)) + async def search_sessions(self, query: str, limit: int = 10) -> list[dict]: if not query.strip(): return [] diff --git a/src/api/app.py b/src/api/app.py index cc4165e..5f87d09 100644 --- a/src/api/app.py +++ b/src/api/app.py @@ -11,7 +11,7 @@ from agent.core import init_agent from agent.db import db -from api.routers import chat, dashboard, history, sessions +from api.routers import auth, chat, dashboard, history, sessions, settings, users class _InterceptHandler(logging.Handler): @@ -65,6 +65,9 @@ async def lifespan(_app: FastAPI): app.include_router(sessions.router) app.include_router(dashboard.router) app.include_router(history.router) +app.include_router(auth.router) +app.include_router(users.router) +app.include_router(settings.router) _DIST = Path(__file__).parent.parent.parent / "frontend" / "dist" diff --git a/src/api/auth.py b/src/api/auth.py new file mode 100644 index 0000000..c00ee73 --- /dev/null +++ b/src/api/auth.py @@ -0,0 +1,62 @@ +"""JWT helpers and FastAPI auth dependencies.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Annotated + +import bcrypt as _bcrypt +from fastapi import Depends, HTTPException +from fastapi.security import OAuth2PasswordBearer + +from config import settings + +_oauth2 = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False) + + +def hash_password(password: str) -> str: + return _bcrypt.hashpw(password.encode(), _bcrypt.gensalt()).decode() + + +def verify_password(plain: str, hashed: str) -> bool: + return _bcrypt.checkpw(plain.encode(), hashed.encode()) + + +def create_access_token(user_id: str, role: str) -> str: + from jose import jwt + + exp = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes) + return jwt.encode( + {"sub": user_id, "role": role, "exp": exp}, + settings.jwt_secret, + algorithm="HS256", + ) + + +async def get_current_user( + token: Annotated[str | None, Depends(_oauth2)], +) -> dict | None: + """Return {id, role} when auth is enabled + token is valid; None in dev mode.""" + if not settings.jwt_secret: + return None + if token is None: + raise HTTPException(status_code=401, detail="Not authenticated") + try: + from jose import JWTError, jwt + + payload = jwt.decode(token, settings.jwt_secret, algorithms=["HS256"]) + user_id: str | None = payload.get("sub") + role: str = payload.get("role", "user") + if not user_id: + raise ValueError("missing sub") + return {"id": user_id, "role": role} + except Exception: + raise HTTPException(status_code=401, detail="Invalid token") + + +async def require_admin( + user: Annotated[dict | None, Depends(get_current_user)], +) -> dict | None: + if user is not None and user["role"] != "admin": + raise HTTPException(status_code=403, detail="Admin access required") + return user diff --git a/src/api/routers/auth.py b/src/api/routers/auth.py new file mode 100644 index 0000000..646e0f9 --- /dev/null +++ b/src/api/routers/auth.py @@ -0,0 +1,71 @@ +"""Auth endpoints: /auth/status, /auth/register, /auth/login, /auth/me.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, EmailStr + +from agent.db import db +from api.auth import create_access_token, get_current_user, hash_password, verify_password +from config import settings + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +class RegisterRequest(BaseModel): + email: EmailStr + name: str + password: str + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + + +@router.get("/status") +async def auth_status() -> dict: + return {"required": bool(settings.jwt_secret)} + + +@router.post("/register", response_model=TokenResponse) +async def register(req: RegisterRequest) -> TokenResponse: + if await db.get_user_by_email(req.email): + raise HTTPException(status_code=400, detail="Email already registered") + is_first = (await db.count_users()) == 0 + role = "admin" if is_first else "user" + user = await db.create_user(req.email, req.name, hash_password(req.password), role) + if not user: + raise HTTPException( + status_code=501, + detail="User management requires CHECKPOINT_BACKEND=postgres", + ) + return TokenResponse(access_token=create_access_token(str(user["id"]), role)) + + +@router.post("/login", response_model=TokenResponse) +async def login(req: LoginRequest) -> TokenResponse: + user = await db.get_user_by_email(req.email) + if not user or not verify_password(req.password, user.get("password_hash") or ""): + raise HTTPException(status_code=401, detail="Invalid credentials") + return TokenResponse( + access_token=create_access_token(str(user["id"]), user["role"]) + ) + + +@router.get("/me") +async def me( + current_user: Annotated[dict | None, Depends(get_current_user)], +) -> dict: + if current_user is None: + return {"id": None, "role": "admin", "name": "You", "auth_enabled": False} + row = await db.get_user_by_id(current_user["id"]) + name = row["name"] if row else "" + return {**current_user, "name": name, "auth_enabled": True} diff --git a/src/api/routers/settings.py b/src/api/routers/settings.py new file mode 100644 index 0000000..8aa3c58 --- /dev/null +++ b/src/api/routers/settings.py @@ -0,0 +1,50 @@ +"""Settings endpoint — exposes read-only runtime configuration.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends + +from api.auth import get_current_user +from config import settings + +router = APIRouter(prefix="/settings", tags=["settings"]) + + +def _mask(value: str | None, show_prefix: int = 4) -> str: + if not value: + return "(not set)" + if len(value) <= show_prefix: + return "••••••••" + return value[:show_prefix] + "••••••••" + + +@router.get("") +async def get_settings( + _user: Annotated[dict | None, Depends(get_current_user)], +) -> dict: + env = [ + {"key": "LLM_MODEL", "value": settings.llm_model, "secret": False}, + {"key": "OPENROUTER_API_KEY", "value": _mask(settings.openrouter_api_key or None), "secret": True}, + {"key": "OPENROUTER_BASE_URL", "value": settings.openrouter_base_url or "(not set)", "secret": False}, + {"key": "LLM_API_KEY", "value": _mask(settings.llm_api_key), "secret": True}, + {"key": "LLM_API_BASE", "value": settings.llm_api_base or "(not set)", "secret": False}, + {"key": "AWS_REGION", "value": settings.aws_region, "secret": False}, + {"key": "AWS_PROFILE", "value": settings.aws_profile or "(not set)", "secret": False}, + {"key": "CHECKPOINT_BACKEND", "value": settings.checkpoint_backend, "secret": False}, + {"key": "DATABASE_URL", "value": _mask(settings.database_url), "secret": True}, + {"key": "SLACK_WEBHOOK_URL", "value": _mask(settings.slack_webhook_url), "secret": True}, + {"key": "JWT_SECRET", "value": _mask(settings.jwt_secret), "secret": True}, + ] + + agent = [ + {"key": "MAX_TOOL_CALLS", "label": "Max tool calls", "value": str(settings.max_tool_calls), "hint": "Hard cap per investigation run"}, + {"key": "INVESTIGATION_TIMEOUT", "label": "Timeout (s)", "value": str(settings.investigation_timeout), "hint": "Max seconds before run is cancelled"}, + {"key": "TOOL_RESPONSE_MAX_CHARS", "label": "Tool response cap", "value": str(settings.tool_response_max_chars), "hint": "Chars before tool output is truncated"}, + {"key": "SUMMARIZATION_ENABLED", "label": "Auto-summarize", "value": str(settings.summarization_enabled).lower(),"hint": "Compact sessions that exceed the threshold"}, + {"key": "SUMMARIZATION_THRESHOLD_CHARS", "label": "Summarize threshold", "value": str(settings.summarization_threshold_chars),"hint": "Total chars in session before compaction fires"}, + {"key": "POLL_INTERVAL_MINUTES", "label": "Poll interval (min)", "value": str(settings.poll_interval_minutes), "hint": "0 = proactive polling disabled"}, + ] + + return {"env": env, "agent": agent} diff --git a/src/api/routers/users.py b/src/api/routers/users.py new file mode 100644 index 0000000..1ba3ec4 --- /dev/null +++ b/src/api/routers/users.py @@ -0,0 +1,64 @@ +"""User management — admin only.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from agent.db import db +from api.auth import hash_password, require_admin +from models.users import UserCreate, UserOut, UserUpdate + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get("", response_model=list[UserOut]) +async def list_users( + _: Annotated[dict | None, Depends(require_admin)], +) -> list[UserOut]: + return await db.list_users() + + +@router.post("", response_model=UserOut, status_code=201) +async def create_user( + req: UserCreate, + _: Annotated[dict | None, Depends(require_admin)], +) -> UserOut: + if await db.get_user_by_email(req.email): + raise HTTPException(status_code=400, detail="Email already registered") + user = await db.create_user(req.email, req.name, hash_password(req.password), req.role) + if not user: + raise HTTPException(status_code=500, detail="Failed to create user") + return user + + +@router.patch("/{user_id}", response_model=UserOut) +async def update_user( + user_id: str, + req: UserUpdate, + _: Annotated[dict | None, Depends(require_admin)], +) -> UserOut: + updates: dict = {} + if req.name is not None: + updates["name"] = req.name + if req.role is not None: + updates["role"] = req.role + if req.password is not None: + updates["password_hash"] = hash_password(req.password) + if not updates: + raise HTTPException(status_code=400, detail="No fields to update") + user = await db.update_user(user_id, **updates) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user + + +@router.delete("/{user_id}", status_code=204) +async def delete_user( + user_id: str, + current_user: Annotated[dict | None, Depends(require_admin)], +) -> None: + if current_user and current_user["id"] == user_id: + raise HTTPException(status_code=400, detail="Cannot delete yourself") + await db.delete_user(user_id) diff --git a/src/config/appsettings.py b/src/config/appsettings.py index ff23da0..a406506 100644 --- a/src/config/appsettings.py +++ b/src/config/appsettings.py @@ -57,5 +57,9 @@ class Settings(BaseSettings): poll_error_threshold: float = 5.0 # % Lambda error rate that triggers investigation poll_reinvestigate_hours: int = 1 # don't re-investigate the same alarm within N hours + # Auth — leave jwt_secret unset to disable auth (dev / memory-backend mode) + jwt_secret: str | None = None + jwt_expire_minutes: int = 1440 # 24 hours + settings = Settings() diff --git a/src/models/users.py b/src/models/users.py new file mode 100644 index 0000000..bec502f --- /dev/null +++ b/src/models/users.py @@ -0,0 +1,37 @@ +from datetime import datetime +from pydantic import BaseModel, EmailStr, field_validator + + +class UserOut(BaseModel): + id: str + email: str + name: str + role: str + created_at: datetime + + +class UserCreate(BaseModel): + email: EmailStr + name: str + password: str + role: str = "user" + + @field_validator("role") + @classmethod + def _valid_role(cls, v: str) -> str: + if v not in ("admin", "user"): + raise ValueError("role must be 'admin' or 'user'") + return v + + +class UserUpdate(BaseModel): + name: str | None = None + role: str | None = None + password: str | None = None + + @field_validator("role") + @classmethod + def _valid_role(cls, v: str | None) -> str | None: + if v is not None and v not in ("admin", "user"): + raise ValueError("role must be 'admin' or 'user'") + return v diff --git a/uv.lock b/uv.lock index 4b62972..8eb9f42 100644 --- a/uv.lock +++ b/uv.lock @@ -232,6 +232,76 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, ] +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, + { url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" }, + { url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" }, +] + [[package]] name = "beartype" version = "0.22.9" @@ -641,6 +711,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] +[[package]] +name = "ecdsa" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" }, +] + [[package]] name = "email-validator" version = "2.3.0" @@ -2067,6 +2149,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, + { name = "bcrypt" }, { name = "boto3" }, { name = "cachetools" }, { name = "deepagents" }, @@ -2085,6 +2168,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-dotenv" }, + { name = "python-jose", extra = ["cryptography"] }, { name = "rich" }, { name = "typer" }, { name = "uvicorn" }, @@ -2102,6 +2186,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiosqlite", specifier = ">=0.20.0" }, + { name = "bcrypt", specifier = ">=5.0.0" }, { name = "boto3", specifier = ">=1.34.0" }, { name = "cachetools", specifier = ">=5.3.0" }, { name = "deepagents" }, @@ -2120,6 +2205,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.7.0" }, { name = "pydantic-settings", specifier = ">=2.3.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" }, { name = "rich", specifier = ">=13.7.0" }, { name = "typer", specifier = ">=0.12.0" }, { name = "uvicorn", specifier = ">=0.30.0" }, @@ -2767,6 +2853,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-jose" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, +] + +[package.optional-dependencies] +cryptography = [ + { name = "cryptography" }, +] + [[package]] name = "python-multipart" version = "0.0.27" @@ -3152,6 +3257,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, ] +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + [[package]] name = "ruff" version = "0.15.11"