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/__init__.py +3 -0
- cync/auth.py +184 -0
- cync/client.py +319 -0
- cync/common.py +185 -0
- cync/config.py +108 -0
- cync/server.py +261 -0
- cync/storage.py +98 -0
- cync/supabase_store.py +199 -0
- cync_cli-0.3.0.dist-info/METADATA +176 -0
- cync_cli-0.3.0.dist-info/RECORD +13 -0
- cync_cli-0.3.0.dist-info/WHEEL +4 -0
- cync_cli-0.3.0.dist-info/entry_points.txt +3 -0
- cync_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
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"]
|