File size: 12,205 Bytes
62b83b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f86beae
 
 
62b83b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f86beae
62b83b2
 
f86beae
 
62b83b2
f86beae
62b83b2
 
 
 
f86beae
 
 
 
 
 
 
 
 
 
 
 
 
62b83b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f86beae
62b83b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a5f2c4b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
"""Postgres persistence (Aiven). Stores users, API keys, sessions, and every message.

Connection string comes from DATABASE_URL (env). All access goes through a small
pool. If DATABASE_URL is unset, available() is False and the server runs without
persistence (in-memory only) so local dev still works.
"""
from __future__ import annotations

from contextlib import contextmanager
from datetime import datetime, timezone

import psycopg2
import psycopg2.extras
from psycopg2.pool import SimpleConnectionPool

from . import config

_pool: SimpleConnectionPool | None = None


def available() -> bool:
    return bool(config.DATABASE_URL)


def init_pool() -> None:
    global _pool
    if _pool is None and available():
        _pool = SimpleConnectionPool(1, 8, dsn=config.DATABASE_URL)


@contextmanager
def cursor(commit: bool = False):
    if _pool is None:
        init_pool()
    if _pool is None:
        raise RuntimeError("DATABASE_URL not configured")
    conn = _pool.getconn()
    try:
        with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
            yield cur
        if commit:
            conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        _pool.putconn(conn)


SCHEMA = """
CREATE TABLE IF NOT EXISTS app_users (
    id            SERIAL PRIMARY KEY,
    username      TEXT UNIQUE NOT NULL,
    password_hash TEXT NOT NULL,
    role          TEXT NOT NULL DEFAULT 'user',
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS login_sessions (
    token      TEXT PRIMARY KEY,
    user_id    INTEGER REFERENCES app_users(id) ON DELETE CASCADE,
    role       TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at TIMESTAMPTZ NOT NULL
);

CREATE TABLE IF NOT EXISTS api_keys (
    id            SERIAL PRIMARY KEY,
    key_hash      TEXT UNIQUE NOT NULL,
    key_prefix    TEXT NOT NULL,
    label         TEXT,
    role          TEXT NOT NULL DEFAULT 'user',
    scopes        TEXT[] NOT NULL DEFAULT ARRAY['chat','websearch']::TEXT[],
    created_by    INTEGER REFERENCES app_users(id) ON DELETE SET NULL,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at    TIMESTAMPTZ,                 -- NULL = lifetime
    revoked       BOOLEAN NOT NULL DEFAULT FALSE,
    request_count INTEGER NOT NULL DEFAULT 0,
    token_count   BIGINT  NOT NULL DEFAULT 0,
    last_used_at  TIMESTAMPTZ
);

CREATE TABLE IF NOT EXISTS chat_sessions (
    session_id      TEXT PRIMARY KEY,
    conversation_id TEXT,
    api_key_id      INTEGER REFERENCES api_keys(id) ON DELETE SET NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    last_active     TIMESTAMPTZ NOT NULL DEFAULT now(),
    turns           INTEGER NOT NULL DEFAULT 0,
    context_tokens  INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE IF NOT EXISTS messages (
    id          BIGSERIAL PRIMARY KEY,
    session_id  TEXT REFERENCES chat_sessions(session_id) ON DELETE CASCADE,
    api_key_id  INTEGER,
    role        TEXT NOT NULL,
    content     TEXT,
    tokens      INTEGER NOT NULL DEFAULT 0,
    has_image   BOOLEAN NOT NULL DEFAULT FALSE,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);

CREATE TABLE IF NOT EXISTS app_settings (
    key        TEXT PRIMARY KEY,
    value      TEXT,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- per-key session cap (NULL/0 = unlimited)
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS max_sessions INTEGER;

-- per-user AGENTS.md context, applied only to this key's workspace (NULL = none)
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS agents_md TEXT;
"""


def count_sessions(api_key_id: int) -> int:
    with cursor() as cur:
        cur.execute("SELECT count(*) AS n FROM chat_sessions WHERE api_key_id=%s", (api_key_id,))
        return int(cur.fetchone()["n"])


def session_exists(session_id: str) -> bool:
    with cursor() as cur:
        cur.execute("SELECT 1 FROM chat_sessions WHERE session_id=%s", (session_id,))
        return cur.fetchone() is not None


def get_setting(key: str) -> str | None:
    with cursor() as cur:
        cur.execute("SELECT value FROM app_settings WHERE key=%s", (key,))
        row = cur.fetchone()
        return row["value"] if row else None


def set_setting(key: str, value: str) -> None:
    with cursor(commit=True) as cur:
        cur.execute(
            """INSERT INTO app_settings (key, value, updated_at) VALUES (%s,%s,now())
               ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=now()""",
            (key, value),
        )


def init_schema() -> None:
    with cursor(commit=True) as cur:
        cur.execute(SCHEMA)


# ---------- users / superadmin ----------
def upsert_user(username: str, password_hash: str, role: str) -> dict:
    with cursor(commit=True) as cur:
        cur.execute(
            """INSERT INTO app_users (username, password_hash, role) VALUES (%s,%s,%s)
               ON CONFLICT (username) DO UPDATE SET password_hash=EXCLUDED.password_hash, role=EXCLUDED.role
               RETURNING id, username, role""",
            (username, password_hash, role),
        )
        return dict(cur.fetchone())


def get_user(username: str) -> dict | None:
    with cursor() as cur:
        cur.execute("SELECT * FROM app_users WHERE username=%s", (username,))
        row = cur.fetchone()
        return dict(row) if row else None


# ---------- login sessions ----------
def create_login(token: str, user_id: int, role: str, expires_at: datetime) -> None:
    with cursor(commit=True) as cur:
        cur.execute(
            "INSERT INTO login_sessions (token, user_id, role, expires_at) VALUES (%s,%s,%s,%s)",
            (token, user_id, role, expires_at),
        )


def get_login(token: str) -> dict | None:
    with cursor() as cur:
        cur.execute("SELECT * FROM login_sessions WHERE token=%s", (token,))
        row = cur.fetchone()
        if not row:
            return None
        if row["expires_at"] <= datetime.now(timezone.utc):
            return None  # expired
        return dict(row)


# ---------- api keys ----------
def create_api_key(key_hash: str, key_prefix: str, label: str, role: str,
                   scopes: list[str], created_by: int | None, expires_at: datetime | None,
                   max_sessions: int | None = None, agents_md: str | None = None) -> dict:
    with cursor(commit=True) as cur:
        cur.execute(
            """INSERT INTO api_keys (key_hash, key_prefix, label, role, scopes, created_by, expires_at, max_sessions, agents_md)
               VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
               RETURNING id, key_prefix, label, role, scopes, expires_at, created_at, max_sessions""",
            (key_hash, key_prefix, label, role, scopes, created_by, expires_at, max_sessions, agents_md),
        )
        return dict(cur.fetchone())


def get_api_key(key_id: int) -> dict | None:
    with cursor() as cur:
        cur.execute("SELECT * FROM api_keys WHERE id=%s", (key_id,))
        row = cur.fetchone()
        return dict(row) if row else None


def set_api_key_agents_md(key_id: int, agents_md: str | None) -> bool:
    with cursor(commit=True) as cur:
        cur.execute("UPDATE api_keys SET agents_md=%s WHERE id=%s", (agents_md, key_id))
        return cur.rowcount > 0


def get_api_key_by_hash(key_hash: str) -> dict | None:
    with cursor() as cur:
        cur.execute("SELECT * FROM api_keys WHERE key_hash=%s", (key_hash,))
        row = cur.fetchone()
        return dict(row) if row else None


def touch_api_key(key_id: int, tokens: int) -> None:
    with cursor(commit=True) as cur:
        cur.execute(
            """UPDATE api_keys SET request_count=request_count+1, token_count=token_count+%s,
               last_used_at=now() WHERE id=%s""",
            (tokens, key_id),
        )


def list_api_keys() -> list[dict]:
    with cursor() as cur:
        cur.execute(
            """SELECT id, key_prefix, label, role, scopes, created_at, expires_at, revoked,
                      request_count, token_count, last_used_at, max_sessions,
                      (agents_md IS NOT NULL AND agents_md <> '') AS has_agents,
                      (SELECT count(*) FROM chat_sessions s WHERE s.api_key_id = api_keys.id) AS sessions_used
               FROM api_keys ORDER BY created_at DESC"""
        )
        return [dict(r) for r in cur.fetchall()]


def revoke_api_key(key_id: int) -> bool:
    with cursor(commit=True) as cur:
        cur.execute("UPDATE api_keys SET revoked=TRUE WHERE id=%s", (key_id,))
        return cur.rowcount > 0


# ---------- chat sessions + messages ----------
def upsert_session(session_id: str, conversation_id: str | None, api_key_id: int | None,
                   turns: int, context_tokens: int) -> None:
    with cursor(commit=True) as cur:
        cur.execute(
            """INSERT INTO chat_sessions (session_id, conversation_id, api_key_id, turns, context_tokens)
               VALUES (%s,%s,%s,%s,%s)
               ON CONFLICT (session_id) DO UPDATE
               SET conversation_id=EXCLUDED.conversation_id, last_active=now(),
                   turns=EXCLUDED.turns, context_tokens=EXCLUDED.context_tokens""",
            (session_id, conversation_id, api_key_id, turns, context_tokens),
        )


def insert_message(session_id: str | None, api_key_id: int | None, role: str,
                   content: str, tokens: int, has_image: bool = False) -> None:
    with cursor(commit=True) as cur:
        cur.execute(
            """INSERT INTO messages (session_id, api_key_id, role, content, tokens, has_image)
               VALUES (%s,%s,%s,%s,%s,%s)""",
            (session_id, api_key_id, role, content, tokens, has_image),
        )


def reserve_session(session_id: str, api_key_id: int | None) -> bool:
    """Create an empty session row (no conversation yet). No-op if it already exists."""
    with cursor(commit=True) as cur:
        cur.execute(
            """INSERT INTO chat_sessions (session_id, api_key_id) VALUES (%s,%s)
               ON CONFLICT (session_id) DO NOTHING""",
            (session_id, api_key_id),
        )
        return cur.rowcount > 0


def get_session(session_id: str) -> dict | None:
    with cursor() as cur:
        cur.execute("SELECT * FROM chat_sessions WHERE session_id=%s", (session_id,))
        row = cur.fetchone()
        return dict(row) if row else None


def list_sessions_for_key(api_key_id: int) -> list[dict]:
    with cursor() as cur:
        cur.execute(
            """SELECT session_id, conversation_id, created_at, last_active, turns, context_tokens
               FROM chat_sessions WHERE api_key_id=%s ORDER BY last_active DESC""",
            (api_key_id,),
        )
        return [dict(r) for r in cur.fetchall()]


def delete_session_for_key(session_id: str, api_key_id: int) -> bool:
    """Delete a session ONLY if it belongs to this key (ownership enforced)."""
    with cursor(commit=True) as cur:
        cur.execute("DELETE FROM chat_sessions WHERE session_id=%s AND api_key_id=%s",
                    (session_id, api_key_id))
        return cur.rowcount > 0


def list_all_sessions(api_key_id: int | None = None) -> list[dict]:
    """Admin view: every session (optionally filtered by key), with the owner label."""
    with cursor() as cur:
        base = (
            """SELECT s.session_id, s.conversation_id, s.api_key_id, s.turns, s.context_tokens,
                      s.created_at, s.last_active, k.label AS owner_label, k.key_prefix
               FROM chat_sessions s LEFT JOIN api_keys k ON k.id = s.api_key_id """
        )
        if api_key_id is not None:
            cur.execute(base + "WHERE s.api_key_id=%s ORDER BY s.last_active DESC", (api_key_id,))
        else:
            cur.execute(base + "ORDER BY s.last_active DESC")
        return [dict(r) for r in cur.fetchall()]


def delete_session(session_id: str) -> bool:
    """Admin: delete any session by id (messages cascade)."""
    with cursor(commit=True) as cur:
        cur.execute("DELETE FROM chat_sessions WHERE session_id=%s", (session_id,))
        return cur.rowcount > 0