cync-cli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cync/config.py ADDED
@@ -0,0 +1,108 @@
1
+ """Configuration for the cync server and client + per-machine project links."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+
10
+ @dataclass
11
+ class ServerConfig:
12
+ """Server-side config, read from environment (see .env.example)."""
13
+
14
+ r2_endpoint: str
15
+ r2_access_key: str
16
+ r2_secret_key: str
17
+ r2_bucket: str
18
+ token: str
19
+ supabase_url: str
20
+ supabase_anon_key: str
21
+
22
+ @classmethod
23
+ def from_env(cls) -> "ServerConfig":
24
+ account = os.environ.get("R2_ACCOUNT_ID", "")
25
+ endpoint = os.environ.get("R2_ENDPOINT") or (
26
+ f"https://{account}.r2.cloudflarestorage.com" if account else ""
27
+ )
28
+ return cls(
29
+ r2_endpoint=endpoint,
30
+ r2_access_key=os.environ.get("R2_ACCESS_KEY_ID", ""),
31
+ r2_secret_key=os.environ.get("R2_SECRET_ACCESS_KEY", ""),
32
+ r2_bucket=os.environ.get("R2_BUCKET", "cync"),
33
+ token=os.environ.get("CYNC_TOKEN", ""),
34
+ supabase_url=os.environ.get("SUPABASE_URL", "").rstrip("/"),
35
+ supabase_anon_key=os.environ.get("SUPABASE_ANON_KEY", ""),
36
+ )
37
+
38
+
39
+ # The default hosted cync server. Users need no config — `pipx install cync`
40
+ # then `cync login` just works. Override with CYNC_SERVER_URL or config.toml
41
+ # (e.g. to self-host).
42
+ DEFAULT_SERVER_URL = "https://cync-486860272818.asia-northeast1.run.app"
43
+
44
+
45
+ @dataclass
46
+ class ClientConfig:
47
+ """Client-side config: the cync server URL (auth is via `cync login`)."""
48
+
49
+ server_url: str
50
+
51
+ @classmethod
52
+ def load(cls) -> "ClientConfig":
53
+ url = os.environ.get("CYNC_SERVER_URL")
54
+ cfg_path = Path.home() / ".config" / "cync" / "config.toml"
55
+ if not url and cfg_path.exists():
56
+ import tomllib
57
+
58
+ url = tomllib.loads(cfg_path.read_text()).get("server_url")
59
+ if not url:
60
+ url = DEFAULT_SERVER_URL # hosted server; override via env/config.toml
61
+ url = url.rstrip("/")
62
+ from urllib.parse import urlparse
63
+
64
+ host = urlparse(url).hostname or ""
65
+ if not url.startswith("https://") and host not in ("127.0.0.1", "localhost", "::1"):
66
+ raise SystemExit(
67
+ f"refusing non-HTTPS cync server URL {url!r} — use https:// "
68
+ "(http is allowed only for localhost)."
69
+ )
70
+ return cls(server_url=url)
71
+
72
+
73
+ # ---- repo -> project links (per machine, ~/.config/cync/links.json) ----
74
+
75
+
76
+ def _links_path() -> Path:
77
+ return Path.home() / ".config" / "cync" / "links.json"
78
+
79
+
80
+ def load_links() -> dict:
81
+ p = _links_path()
82
+ if p.exists():
83
+ try:
84
+ return json.loads(p.read_text())
85
+ except Exception:
86
+ return {}
87
+ return {}
88
+
89
+
90
+ def get_link(repo_root: str) -> str | None:
91
+ return load_links().get(repo_root)
92
+
93
+
94
+ def set_link(repo_root: str, project_id: str) -> None:
95
+ p = _links_path()
96
+ p.parent.mkdir(parents=True, exist_ok=True)
97
+ links = load_links()
98
+ links[repo_root] = project_id
99
+ p.write_text(json.dumps(links, indent=2))
100
+
101
+
102
+ def remove_link(repo_root: str) -> bool:
103
+ links = load_links()
104
+ if repo_root in links:
105
+ del links[repo_root]
106
+ _links_path().write_text(json.dumps(links, indent=2))
107
+ return True
108
+ return False
cync/server.py ADDED
@@ -0,0 +1,261 @@
1
+ """cync server (v0.3) — FastAPI: Supabase GitHub-JWT auth, Postgres metadata, R2 blobs.
2
+
3
+ Auth: clients send a Supabase user JWT as ``Authorization: Bearer <jwt>``. The
4
+ server validates it (one call to Supabase ``/auth/v1/user``), gets the user id,
5
+ and scopes every project/conversation to that owner. Metadata lives in Supabase
6
+ Postgres; blobs live in R2 keyed by the project uuid.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import gzip
12
+ import os
13
+ from functools import lru_cache
14
+
15
+ from dotenv import load_dotenv
16
+
17
+ load_dotenv()
18
+
19
+ from fastapi import Depends, FastAPI, Header, HTTPException, Request # noqa: E402
20
+ from fastapi.responses import JSONResponse # noqa: E402
21
+ from pydantic import BaseModel # noqa: E402
22
+
23
+ from . import common # noqa: E402
24
+ from .config import ServerConfig # noqa: E402
25
+ from .storage import make_storage # noqa: E402
26
+ from .supabase_store import SupabaseStore # noqa: E402
27
+
28
+ cfg = ServerConfig.from_env()
29
+ app = FastAPI(title="cync", version="0.3.0")
30
+
31
+ MAX_BODY_BYTES = int(os.environ.get("CYNC_MAX_BODY_BYTES", str(64 * 1024 * 1024)))
32
+ # Per-user quotas for the hosted (public) service. Generous defaults; tune via env.
33
+ MAX_PROJECTS = int(os.environ.get("CYNC_MAX_PROJECTS", "20"))
34
+ MAX_BYTES_PER_USER = int(os.environ.get("CYNC_MAX_BYTES_PER_USER", str(500 * 1024 * 1024)))
35
+
36
+
37
+ @lru_cache(maxsize=1)
38
+ def get_storage():
39
+ """R2 (or local) blob store — lazy so import never crashes on bad config."""
40
+ return make_storage(cfg)
41
+
42
+
43
+ @lru_cache(maxsize=1)
44
+ def get_meta():
45
+ """Supabase Postgres metadata store — lazy."""
46
+ return SupabaseStore.from_env()
47
+
48
+
49
+ @app.middleware("http")
50
+ async def _limit_body_size(request: Request, call_next):
51
+ cl = request.headers.get("content-length")
52
+ if cl is not None:
53
+ try:
54
+ if int(cl) > MAX_BODY_BYTES:
55
+ return JSONResponse(status_code=413, content={"detail": "request body too large"})
56
+ except ValueError:
57
+ pass
58
+ # Enforce the cap against the actual bytes too (chunked / lying Content-Length):
59
+ # read+count the stream, then hand the buffered body to the route.
60
+ if request.method in ("POST", "PUT", "PATCH"):
61
+ body = b""
62
+ async for chunk in request.stream():
63
+ body += chunk
64
+ if len(body) > MAX_BODY_BYTES:
65
+ return JSONResponse(status_code=413, content={"detail": "request body too large"})
66
+ request._body = body # cache so the handler re-uses it
67
+ return await call_next(request)
68
+
69
+
70
+ def _allowlist() -> set[str]:
71
+ """Emails/GitHub usernames allowed to use this server (CYNC_ALLOWLIST,
72
+ comma-separated). Empty/unset = open to any authenticated GitHub user."""
73
+ raw = os.environ.get("CYNC_ALLOWLIST", "")
74
+ return {x.strip().lower() for x in raw.split(",") if x.strip()}
75
+
76
+
77
+ def require_user(authorization: str = Header(default="")) -> str:
78
+ """Validate the Supabase JWT, enforce the allowlist, return the user's id."""
79
+ scheme, _, token = authorization.partition(" ")
80
+ if scheme != "Bearer" or not token:
81
+ raise HTTPException(status_code=401, detail="missing bearer token")
82
+ try:
83
+ user = get_meta().get_user(token)
84
+ except Exception: # auth backend unreachable -> fail closed, but as 503 not 500
85
+ raise HTTPException(status_code=503, detail="auth backend unavailable")
86
+ if not user or not user.get("id"):
87
+ raise HTTPException(status_code=401, detail="invalid or expired token")
88
+ allow = _allowlist()
89
+ if allow:
90
+ email = (user.get("email") or "").lower()
91
+ uname = ((user.get("user_metadata") or {}).get("user_name") or "").lower()
92
+ if email not in allow and uname not in allow:
93
+ raise HTTPException(
94
+ status_code=403,
95
+ detail="not authorized — ask the admin to add you to the allowlist",
96
+ )
97
+ return user["id"]
98
+
99
+
100
+ def _seg(value: str) -> str:
101
+ if not common.is_safe_segment(value):
102
+ raise HTTPException(status_code=400, detail="invalid id")
103
+ return value
104
+
105
+
106
+ def _own_project(owner: str, pid: str) -> dict:
107
+ """Ensure the user owns project `pid` (also validates it as a path segment)."""
108
+ _seg(pid)
109
+ proj = get_meta().get_project_by_id(owner, pid)
110
+ if not proj:
111
+ raise HTTPException(status_code=404, detail="project not found")
112
+ return proj
113
+
114
+
115
+ class PushBody(BaseModel):
116
+ project_id: str
117
+ origin_path: str = ""
118
+ title: str = ""
119
+ mtime: float = 0.0
120
+ content_b64: str
121
+
122
+
123
+ class CreateProjectBody(BaseModel):
124
+ name: str
125
+
126
+
127
+ @app.get("/health")
128
+ def health() -> dict:
129
+ return {"ok": True, "service": "cync"}
130
+
131
+
132
+ @app.get("/config")
133
+ def public_config() -> dict:
134
+ """Public Supabase params for the CLI/web login flow (the anon key is public)."""
135
+ return {"supabase_url": cfg.supabase_url, "supabase_anon_key": cfg.supabase_anon_key}
136
+
137
+
138
+ # ---- projects ----
139
+
140
+
141
+ @app.post("/projects")
142
+ def create_project(body: CreateProjectBody, owner: str = Depends(require_user)) -> dict:
143
+ slug = common.slugify_project(body.name)
144
+ if not slug or not common.is_safe_segment(slug):
145
+ raise HTTPException(status_code=400, detail="invalid project name")
146
+ meta = get_meta()
147
+ if meta.get_project(owner, slug) is not None:
148
+ raise HTTPException(status_code=409, detail=f"project '{slug}' already exists")
149
+ if len(meta.list_projects(owner)) >= MAX_PROJECTS:
150
+ raise HTTPException(status_code=403, detail=f"project limit reached ({MAX_PROJECTS})")
151
+ return meta.create_project(owner, slug, body.name)
152
+
153
+
154
+ @app.get("/projects")
155
+ def list_projects(owner: str = Depends(require_user)) -> list[dict]:
156
+ return get_meta().list_projects(owner)
157
+
158
+
159
+ @app.get("/projects/{pid}")
160
+ def get_project(pid: str, owner: str = Depends(require_user)) -> dict:
161
+ return _own_project(owner, pid)
162
+
163
+
164
+ @app.delete("/projects/{pid}")
165
+ def delete_project(pid: str, owner: str = Depends(require_user)) -> dict:
166
+ _own_project(owner, pid)
167
+ # blobs first (R2 delete is idempotent), then metadata — so a storage
168
+ # failure leaves the rows intact and the delete is safely retryable.
169
+ get_storage().delete_project(pid) # delete R2 blobs
170
+ get_meta().delete_project(owner, pid) # then metadata (cascades rows)
171
+ return {"deleted": pid}
172
+
173
+
174
+ # ---- conversations ----
175
+
176
+
177
+ @app.put("/conversations/{cid}")
178
+ def put_conversation(cid: str, body: PushBody, owner: str = Depends(require_user)) -> dict:
179
+ _seg(cid)
180
+ _own_project(owner, body.project_id)
181
+ try:
182
+ raw = base64.b64decode(body.content_b64)
183
+ except Exception:
184
+ raise HTTPException(status_code=400, detail="invalid base64 content")
185
+ usage = get_meta().user_usage(owner)
186
+ old = usage["sizes"].get(f"{body.project_id}/{cid}", 0)
187
+ if usage["bytes"] - old + len(raw) > MAX_BYTES_PER_USER:
188
+ raise HTTPException(
189
+ status_code=413,
190
+ detail=f"storage quota exceeded ({MAX_BYTES_PER_USER // (1024 * 1024)} MB per user)",
191
+ )
192
+ get_storage().put(body.project_id, cid, gzip.compress(raw))
193
+ meta = {
194
+ "title": body.title or common.extract_title(raw.decode("utf-8", "ignore")),
195
+ "size": len(raw),
196
+ "mtime": body.mtime,
197
+ "origin_path": body.origin_path,
198
+ "updated_at": common.now_iso(),
199
+ }
200
+ return get_meta().upsert_conversation(body.project_id, cid, meta)
201
+
202
+
203
+ @app.get("/conversations")
204
+ def list_conversations(project_id: str, owner: str = Depends(require_user)) -> list[dict]:
205
+ _own_project(owner, project_id)
206
+ return get_meta().list_conversations(project_id)
207
+
208
+
209
+ @app.get("/conversations/{cid}")
210
+ def get_conversation(cid: str, project_id: str, owner: str = Depends(require_user)) -> dict:
211
+ _seg(cid)
212
+ _own_project(owner, project_id)
213
+ return _load(project_id, cid)
214
+
215
+
216
+ @app.delete("/conversations/{cid}")
217
+ def delete_conversation(cid: str, project_id: str, owner: str = Depends(require_user)) -> dict:
218
+ _seg(cid)
219
+ _own_project(owner, project_id)
220
+ get_storage().delete(project_id, cid)
221
+ get_meta().delete_conversation(project_id, cid)
222
+ return {"deleted": cid}
223
+
224
+
225
+ # ---- browse endpoints (web viewer; keyed by project uuid) ----
226
+
227
+
228
+ @app.get("/projects/{pid}/conversations")
229
+ def browse_conversations(pid: str, owner: str = Depends(require_user)) -> list[dict]:
230
+ _own_project(owner, pid)
231
+ return get_meta().list_conversations(pid)
232
+
233
+
234
+ @app.get("/projects/{pid}/conversations/{cid}")
235
+ def browse_conversation(pid: str, cid: str, owner: str = Depends(require_user)) -> dict:
236
+ _seg(cid)
237
+ _own_project(owner, pid)
238
+ return _load(pid, cid)
239
+
240
+
241
+ def _load(pid: str, cid: str) -> dict:
242
+ meta = get_meta().get_conversation(pid, cid)
243
+ if not meta:
244
+ raise HTTPException(status_code=404, detail="conversation not found")
245
+ try:
246
+ gz = get_storage().get(pid, cid)
247
+ except Exception:
248
+ raise HTTPException(status_code=404, detail="conversation blob not found")
249
+ raw = gzip.decompress(gz)
250
+ return {**meta, "content_b64": base64.b64encode(raw).decode()}
251
+
252
+
253
+ def run() -> None:
254
+ import uvicorn
255
+
256
+ uvicorn.run(
257
+ "cync.server:app",
258
+ host=os.environ.get("CYNC_HOST", "127.0.0.1"),
259
+ port=int(os.environ.get("CYNC_PORT", "8787")),
260
+ reload=False,
261
+ )
cync/storage.py ADDED
@@ -0,0 +1,98 @@
1
+ """Blob storage for cync (v0.3): conversation .jsonl.gz blobs only.
2
+
3
+ Project/conversation *metadata* lives in Supabase Postgres (see
4
+ supabase_store.py). This layer stores just the gzipped transcripts, keyed by:
5
+ <project_id>/<conv_id>.jsonl.gz (project_id is the Postgres project uuid)
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import shutil
11
+ from pathlib import Path
12
+
13
+ from .config import ServerConfig
14
+
15
+
16
+ def make_storage(cfg: ServerConfig):
17
+ """Pick a blob backend from CYNC_STORAGE (default: r2)."""
18
+ backend = os.environ.get("CYNC_STORAGE", "r2").lower()
19
+ if backend == "local":
20
+ base = os.environ.get("CYNC_LOCAL_DIR") or str(Path.home() / ".cync-store")
21
+ return LocalStorage(base)
22
+ if not (cfg.r2_endpoint and cfg.r2_access_key and cfg.r2_secret_key):
23
+ raise RuntimeError(
24
+ "R2 storage is not configured: set R2_ACCOUNT_ID (or R2_ENDPOINT) and "
25
+ "R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY, or use CYNC_STORAGE=local."
26
+ )
27
+ return R2Storage(cfg)
28
+
29
+
30
+ class LocalStorage:
31
+ """Filesystem blob backend — for testing / single-machine use."""
32
+
33
+ def __init__(self, base_dir: str) -> None:
34
+ self.base = Path(base_dir)
35
+
36
+ def _resolve(self, pid: str, name: str) -> Path:
37
+ base = self.base.resolve()
38
+ p = (base / pid / name).resolve()
39
+ if not p.is_relative_to(base):
40
+ raise ValueError("path escapes store root")
41
+ return p
42
+
43
+ def put(self, pid: str, cid: str, data_gz: bytes) -> None:
44
+ self._resolve(pid, "").mkdir(parents=True, exist_ok=True)
45
+ self._resolve(pid, f"{cid}.jsonl.gz").write_bytes(data_gz)
46
+
47
+ def get(self, pid: str, cid: str) -> bytes:
48
+ return self._resolve(pid, f"{cid}.jsonl.gz").read_bytes()
49
+
50
+ def delete(self, pid: str, cid: str) -> None:
51
+ p = self._resolve(pid, f"{cid}.jsonl.gz")
52
+ if p.exists():
53
+ p.unlink()
54
+
55
+ def delete_project(self, pid: str) -> None:
56
+ d = self._resolve(pid, "")
57
+ if d.is_dir():
58
+ shutil.rmtree(d)
59
+
60
+
61
+ class R2Storage:
62
+ """Cloudflare R2 (S3-compatible) blob backend."""
63
+
64
+ def __init__(self, cfg: ServerConfig) -> None:
65
+ import boto3
66
+ from botocore.config import Config
67
+
68
+ self.bucket = cfg.r2_bucket
69
+ self.s3 = boto3.client(
70
+ "s3",
71
+ endpoint_url=cfg.r2_endpoint,
72
+ aws_access_key_id=cfg.r2_access_key,
73
+ aws_secret_access_key=cfg.r2_secret_key,
74
+ config=Config(signature_version="s3v4"),
75
+ region_name="auto",
76
+ )
77
+
78
+ @staticmethod
79
+ def _key(pid: str, cid: str) -> str:
80
+ return f"{pid}/{cid}.jsonl.gz"
81
+
82
+ def put(self, pid: str, cid: str, data_gz: bytes) -> None:
83
+ self.s3.put_object(
84
+ Bucket=self.bucket, Key=self._key(pid, cid),
85
+ Body=data_gz, ContentType="application/gzip",
86
+ )
87
+
88
+ def get(self, pid: str, cid: str) -> bytes:
89
+ return self.s3.get_object(Bucket=self.bucket, Key=self._key(pid, cid))["Body"].read()
90
+
91
+ def delete(self, pid: str, cid: str) -> None:
92
+ self.s3.delete_object(Bucket=self.bucket, Key=self._key(pid, cid))
93
+
94
+ def delete_project(self, pid: str) -> None:
95
+ paginator = self.s3.get_paginator("list_objects_v2")
96
+ for page in paginator.paginate(Bucket=self.bucket, Prefix=f"{pid}/"):
97
+ for obj in page.get("Contents", []):
98
+ self.s3.delete_object(Bucket=self.bucket, Key=obj["Key"])
cync/supabase_store.py ADDED
@@ -0,0 +1,199 @@
1
+ """Supabase Postgres metadata store (via PostgREST, service role) + auth helper.
2
+
3
+ Holds project + conversation *metadata*; the conversation blobs live in R2
4
+ (see storage.py). The server uses the service-role key, which BYPASSES RLS, so
5
+ every query here passes ``owner`` explicitly — RLS in schema.sql is a backstop
6
+ for any direct PostgREST access with a user JWT.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ import httpx
13
+
14
+
15
+ class SupabaseError(RuntimeError):
16
+ pass
17
+
18
+
19
+ def _norm_conv(row: dict) -> dict:
20
+ """Normalize a conversations row to the API shape (conv_id -> id)."""
21
+ return {
22
+ "id": row["conv_id"],
23
+ "project_id": row["project_id"],
24
+ "title": row.get("title", ""),
25
+ "size": row.get("size", 0),
26
+ "mtime": row.get("mtime", 0),
27
+ "origin_path": row.get("origin_path", ""),
28
+ "updated_at": row.get("updated_at"),
29
+ }
30
+
31
+
32
+ class SupabaseStore:
33
+ def __init__(self, url: str, service_key: str) -> None:
34
+ base = url.rstrip("/")
35
+ self.rest = f"{base}/rest/v1"
36
+ self.auth = f"{base}/auth/v1"
37
+ self._key = service_key
38
+ self._h = {
39
+ "apikey": service_key,
40
+ "Authorization": f"Bearer {service_key}",
41
+ "Content-Type": "application/json",
42
+ }
43
+ self.client = httpx.Client(timeout=30.0)
44
+
45
+ @classmethod
46
+ def from_env(cls) -> "SupabaseStore":
47
+ url = os.environ.get("SUPABASE_URL", "")
48
+ key = os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "")
49
+ if not url or not key:
50
+ raise SupabaseError(
51
+ "Supabase is not configured: set SUPABASE_URL and "
52
+ "SUPABASE_SERVICE_ROLE_KEY (see .env.example)."
53
+ )
54
+ return cls(url, key)
55
+
56
+ def _req(self, method: str, path: str, *, params=None, json=None, prefer=None):
57
+ headers = dict(self._h)
58
+ if prefer:
59
+ headers["Prefer"] = prefer
60
+ r = self.client.request(method, f"{self.rest}{path}", params=params, json=json, headers=headers)
61
+ if r.status_code >= 400:
62
+ raise SupabaseError(f"{method} {path} -> {r.status_code}: {r.text[:200]}")
63
+ return r
64
+
65
+ # ---- auth ----
66
+ def get_user(self, access_token: str) -> dict | None:
67
+ """Validate a user's JWT and return their auth.users record (or None)."""
68
+ r = self.client.get(
69
+ f"{self.auth}/user",
70
+ headers={"apikey": self._key, "Authorization": f"Bearer {access_token}"},
71
+ )
72
+ return r.json() if r.status_code == 200 else None
73
+
74
+ # ---- projects ----
75
+ def create_project(self, owner: str, slug: str, name: str) -> dict:
76
+ r = self._req(
77
+ "POST", "/projects",
78
+ json={"owner": owner, "slug": slug, "name": name},
79
+ prefer="return=representation",
80
+ )
81
+ return r.json()[0]
82
+
83
+ def get_project(self, owner: str, slug: str) -> dict | None:
84
+ r = self._req("GET", "/projects", params={
85
+ "owner": f"eq.{owner}", "slug": f"eq.{slug}", "select": "*", "limit": "1",
86
+ })
87
+ rows = r.json()
88
+ return rows[0] if rows else None
89
+
90
+ def get_project_by_id(self, owner: str, project_id: str) -> dict | None:
91
+ r = self._req("GET", "/projects", params={
92
+ "owner": f"eq.{owner}", "id": f"eq.{project_id}", "select": "*", "limit": "1",
93
+ })
94
+ rows = r.json()
95
+ return rows[0] if rows else None
96
+
97
+ def list_projects(self, owner: str) -> list[dict]:
98
+ r = self._req("GET", "/projects", params={
99
+ "owner": f"eq.{owner}",
100
+ "select": "id,slug,name,created_at,conversations(count)",
101
+ "order": "created_at.desc",
102
+ })
103
+ out = []
104
+ for row in r.json():
105
+ embedded = row.get("conversations") or []
106
+ count = embedded[0].get("count", 0) if embedded else 0
107
+ out.append({
108
+ "id": row["id"], "slug": row["slug"], "name": row["name"],
109
+ "created_at": row["created_at"], "count": count,
110
+ })
111
+ return out
112
+
113
+ def user_usage(self, owner: str) -> dict:
114
+ """Per-user quota accounting: project count, total stored bytes, and each
115
+ conversation's size keyed as 'project_id/conv_id'."""
116
+ projects = self.list_projects(owner)
117
+ pids = [p["id"] for p in projects]
118
+ sizes: dict[str, int] = {}
119
+ total = 0
120
+ if pids:
121
+ r = self._req("GET", "/conversations", params={
122
+ "project_id": f"in.({','.join(pids)})",
123
+ "select": "project_id,conv_id,size",
124
+ })
125
+ for row in r.json():
126
+ sz = int(row.get("size") or 0)
127
+ sizes[f"{row['project_id']}/{row['conv_id']}"] = sz
128
+ total += sz
129
+ return {"projects": len(projects), "bytes": total, "sizes": sizes}
130
+
131
+ def delete_project(self, owner: str, project_id: str) -> None:
132
+ self._req("DELETE", "/projects", params={
133
+ "owner": f"eq.{owner}", "id": f"eq.{project_id}",
134
+ })
135
+
136
+ # ---- conversations (metadata) ----
137
+ def upsert_conversation(self, project_id: str, conv_id: str, meta: dict) -> dict:
138
+ body = {
139
+ "project_id": project_id,
140
+ "conv_id": conv_id,
141
+ "title": meta.get("title", ""),
142
+ "size": meta.get("size", 0),
143
+ "mtime": meta.get("mtime", 0),
144
+ "origin_path": meta.get("origin_path", ""),
145
+ }
146
+ # Only send updated_at if we actually have one; otherwise omit the key so
147
+ # the column's NOT NULL `default now()` applies (legacy sidecars lack it).
148
+ if meta.get("updated_at") is not None:
149
+ body["updated_at"] = meta["updated_at"]
150
+ r = self._req(
151
+ "POST", "/conversations", json=body,
152
+ prefer="resolution=merge-duplicates,return=representation",
153
+ )
154
+ return _norm_conv(r.json()[0])
155
+
156
+ def list_conversations(self, project_id: str) -> list[dict]:
157
+ r = self._req("GET", "/conversations", params={
158
+ "project_id": f"eq.{project_id}", "select": "*", "order": "updated_at.desc",
159
+ })
160
+ return [_norm_conv(row) for row in r.json()]
161
+
162
+ def get_conversation(self, project_id: str, conv_id: str) -> dict | None:
163
+ r = self._req("GET", "/conversations", params={
164
+ "project_id": f"eq.{project_id}", "conv_id": f"eq.{conv_id}",
165
+ "select": "*", "limit": "1",
166
+ })
167
+ rows = r.json()
168
+ return _norm_conv(rows[0]) if rows else None
169
+
170
+ def delete_conversation(self, project_id: str, conv_id: str) -> None:
171
+ self._req("DELETE", "/conversations", params={
172
+ "project_id": f"eq.{project_id}", "conv_id": f"eq.{conv_id}",
173
+ })
174
+
175
+ # ---- admin (tests / seeding) ----
176
+ def admin_create_user(self, email: str, password: str | None = None) -> dict:
177
+ body = {"email": email, "email_confirm": True}
178
+ if password:
179
+ body["password"] = password
180
+ r = self.client.post(f"{self.auth}/admin/users", headers=self._h, json=body)
181
+ if r.status_code >= 400:
182
+ raise SupabaseError(f"create_user -> {r.status_code}: {r.text[:200]}")
183
+ return r.json()
184
+
185
+ def admin_delete_user(self, user_id: str) -> None:
186
+ r = self.client.delete(f"{self.auth}/admin/users/{user_id}", headers=self._h)
187
+ if r.status_code >= 400:
188
+ raise SupabaseError(f"delete_user -> {r.status_code}: {r.text[:200]}")
189
+
190
+ def password_token(self, email: str, password: str) -> str:
191
+ """Sign in with email+password and return an access token (for tests)."""
192
+ r = self.client.post(
193
+ f"{self.auth}/token", params={"grant_type": "password"},
194
+ headers={"apikey": self._key, "Content-Type": "application/json"},
195
+ json={"email": email, "password": password},
196
+ )
197
+ if r.status_code >= 400:
198
+ raise SupabaseError(f"password_token -> {r.status_code}: {r.text[:200]}")
199
+ return r.json()["access_token"]