Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions cloud/SUPABASE-SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,35 @@ SELECT * FROM pg_trigger WHERE tgname LIKE '%handle_new_user%';
```

Should return at least one row. If missing, re-run the relevant migration.

## 7. Demo seed on signup (migration 004)

Migration `004_seed_demo_brain.sql` seeds a fully-populated demo brain when a new user signs up. Sims (S101–S103) showed users abandon when day-1 dashboards are empty.

**What gets created:** 1 brain (`metadata.is_demo = true`), 8 lessons across the 6-dim taxonomy, 25 corrections in a Wozniak decay shape, 4 meta-rules, 6 events.

**How users clear it:**

```
POST /api/v1/brains/{brain_id}/clear-demo
```
Comment on lines +126 to +128
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add a language to this fenced block.

This is already tripping markdownlint (MD040), so the runbook will keep the docs check noisy until the fence is annotated.

📝 Minimal fix
-```
+```text
 POST /api/v1/brains/{brain_id}/clear-demo
</details>

<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion

🧰 Tools
🪛 markdownlint-cli2 (0.22.0)

[warning] 126-126: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/SUPABASE-SETUP.md` around lines 126 - 128, The fenced code block
containing "POST /api/v1/brains/{brain_id}/clear-demo" is missing a language
specifier and triggers markdownlint MD040; update the fence from ``` to ```text
(or another appropriate language) so the block reads ```text followed by the
POST line and closing ```, ensuring the fenced block is annotated to satisfy the
linter.


Response: `{ deleted: int, by_table: {...} }`. Only rows with `is_demo` flag are removed; real lessons are never touched.

**Test signup → seed locally:**

```sql
SELECT handle_new_user_test(
'00000000-0000-0000-0000-000000000001'::uuid,
'Local Dev'
);
SELECT count(*) FROM lessons WHERE brain_id = '<returned uuid>'; -- expect 8
SELECT count(*) FROM corrections WHERE brain_id = '<returned uuid>'; -- expect 25
```

**Tear down:**

```sql
DELETE FROM brains WHERE id = '<returned uuid>';
-- ON DELETE CASCADE removes child rows.
```
65 changes: 57 additions & 8 deletions cloud/app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
# Cache JWKS so we don't fetch on every request
_jwks_cache: dict | None = None

# Operator allowlist — god-mode admin access
OPERATOR_DOMAINS = {"gradata.ai", "sprites.ai"}


async def _get_jwks() -> dict:
"""Fetch and cache the Supabase JWKS for ES256 verification."""
Expand Down Expand Up @@ -47,8 +50,8 @@ async def verify_api_key(key: str) -> dict:
return rows[0]


async def verify_jwt(signed_jwt: str) -> str:
"""Verify a Supabase JWT (ES256 or HS256) and return the user_id."""
async def verify_jwt_claims(signed_jwt: str) -> dict:
"""Verify a Supabase JWT (ES256 or HS256) and return the full claims dict."""
settings = get_settings()

# Try ES256 with JWKS first (modern Supabase projects)
Expand All @@ -67,10 +70,9 @@ async def verify_jwt(signed_jwt: str) -> str:
algorithms=["ES256"],
options={"verify_aud": False},
)
user_id = claims.get("sub")
if not user_id:
if not claims.get("sub"):
raise HTTPException(status_code=401, detail="Invalid JWT: no sub claim")
return user_id
return claims
except JWTError:
pass # Fall through to HS256

Expand All @@ -82,14 +84,19 @@ async def verify_jwt(signed_jwt: str) -> str:
algorithms=["HS256"],
options={"verify_aud": False},
)
user_id = claims.get("sub")
if not user_id:
if not claims.get("sub"):
raise HTTPException(status_code=401, detail="Invalid JWT: no sub claim")
return user_id
return claims
except JWTError as e:
raise HTTPException(status_code=401, detail=f"Invalid JWT: {e}") from e


async def verify_jwt(signed_jwt: str) -> str:
"""Verify a Supabase JWT and return just the user_id."""
claims = await verify_jwt_claims(signed_jwt)
return claims["sub"]


async def get_current_brain(
credentials: HTTPAuthorizationCredentials = Security(_bearer),
) -> dict:
Expand Down Expand Up @@ -144,3 +151,45 @@ async def get_brain_for_request(

user_id = await verify_jwt(cred)
return await verify_brain_ownership(brain_id, user_id)


async def _resolve_user_email(user_id: str, claims: dict) -> str | None:
"""Resolve the caller's email — prefer JWT claim, fall back to auth.users lookup."""
email = claims.get("email")
if email:
return email

# Fallback: query auth.users via the Supabase service-role client.
# Supabase exposes auth users through the admin REST endpoint, not PostgREST.
# If your db wrapper doesn't expose that, the JWT claim path is the primary route.
try:
db = get_db()
rows = await db.select("users", columns="email", filters={"id": user_id})
if rows:
return rows[0].get("email")
except Exception as exc: # pragma: no cover - defensive
_log.warning("Failed to resolve email for user=%s: %s", user_id, exc)
return None
Comment on lines +156 to +172
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Searching for users-table definitions and auth.users access..."
rg -n -C2 'CREATE TABLE .*users|auth\.users|select\("users"|from\("users"' cloud

echo
echo "Inspecting the DB wrapper select() API..."
rg -n -C3 'async def select\(|def select\(' cloud/app

Repository: Gradata/gradata

Length of output: 3541


🏁 Script executed:

# Find the DB wrapper implementation and get_db() function
rg -n 'def get_db|class.*DB|async def select' cloud/app --type py -B2 -A5

Repository: Gradata/gradata

Length of output: 1027


🏁 Script executed:

# Check if there's a public "users" table definition in migrations
rg -n 'CREATE TABLE.*users|CREATE TABLE "users"' cloud/migrations

Repository: Gradata/gradata

Length of output: 167


🏁 Script executed:

# Look for service-role or admin API configuration
rg -n 'service.role|admin_api|supabase.*key' cloud --type py

Repository: Gradata/gradata

Length of output: 1736


🏁 Script executed:

# Read the complete select() method implementation
sed -n '32,80p' cloud/app/db.py

Repository: Gradata/gradata

Length of output: 2104


🏁 Script executed:

# Check if SupabaseClient is external or custom, and how it's imported
head -n 30 cloud/app/db.py

Repository: Gradata/gradata

Length of output: 901


🏁 Script executed:

# Look for any special "users" table handling or mapping in the codebase
rg -n 'users.*table|table.*users|auth\.users' cloud/app --type py

Repository: Gradata/gradata

Length of output: 485


Fix the email fallback: db.select("users") won't reach auth.users via PostgREST.

The code calls db.select("users", ...), which routes to PostgREST /rest/v1/users, but there is no public users table in the migrations. The auth.users table exists in Supabase's auth schema and is not exposed through PostgREST—it requires the Admin API instead. As written, the fallback will fail silently and always return None for JWTs without an email claim.

Either:

  • Create a public users view that mirrors auth.users email data, or
  • Replace the PostgREST call with direct Admin API access via Supabase client's auth methods (e.g., supabase.auth.admin.get_user(user_id)), or
  • Remove the fallback and require the email claim in JWTs.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/app/auth.py` around lines 156 - 172, The fallback in
_resolve_user_email currently calls get_db() and db.select("users", ...) which
hits PostgREST and cannot access auth.users; replace that fallback with a
Supabase Admin API call (e.g., use your Supabase client method
supabase.auth.admin.get_user(user_id) or equivalent client helper) to fetch the
user and return user.data.user.email, handling errors and logging similarly;
remove or stop using get_db()/db.select for auth users, ensure you import/obtain
the admin-capable Supabase client (symbol name you use for the client) and catch
exceptions to _log.warning(...) before returning None.



async def require_operator(
credentials: HTTPAuthorizationCredentials = Security(_bearer),
) -> str:
"""Require the caller's email domain to be in OPERATOR_DOMAINS.

Resolves email from the JWT's ``email`` claim when present; otherwise falls
back to a ``users`` table lookup by ``user_id``. Raises 403 otherwise.
Returns the user_id for downstream use.
"""
claims = await verify_jwt_claims(credentials.credentials)
user_id = claims["sub"]

email = await _resolve_user_email(user_id, claims)
if not email:
raise HTTPException(status_code=403, detail="Operator access requires a verified email")

domain = email.split("@", 1)[1].lower() if "@" in email else ""
if domain not in OPERATOR_DOMAINS:
raise HTTPException(status_code=403, detail="Operator access denied")

return user_id
15 changes: 15 additions & 0 deletions cloud/app/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,21 @@ async def update(
resp.raise_for_status()
return resp.json()

async def delete(
self, table: str, filters: dict[str, Any] | None = None,
) -> list[dict]:
"""DELETE rows matching eq filters. Returns deleted rows when PostgREST sends them back."""
params: dict[str, str] = {}
if filters:
for key, val in filters.items():
params[key] = f"eq.{val}"
resp = await self._http.delete(f"/{table}", params=params)
resp.raise_for_status()
try:
return resp.json()
except ValueError:
return []
Comment on lines +69 to +82
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

In PostgREST / Supabase REST, what happens when you send DELETE /rest/v1/<table> without any filter query parameters?

💡 Result:

In PostgREST (and thus Supabase REST API), sending DELETE /rest/v1/<table> without any filter query parameters generates a SQL DELETE statement with no WHERE clause, which deletes all rows in the table that the authenticated database role has DELETE permission on. This behavior follows PostgreSQL semantics: without a WHERE clause, DELETE removes all visible rows. If Row Level Security (RLS) is enabled, only rows passing the relevant DELETE policies are deleted (silently; no error if zero rows affected). The response is typically HTTP 204 No Content. Official docs warn: "Beware of accidentally deleting all rows in a table." (PostgREST v14 docs). To prevent this, use the pg-safeupdate extension (blocks WHERE-less DELETE/UPDATE), database permissions/RLS, or a reverse proxy like Nginx. Supabase docs emphasize: "Always include a filter when deleting to avoid deleting all rows in the table." Community discussions confirm users must add filters (e.g., ?id=eq.0 or ?limit=0 workaround discouraged). No built-in block in recent PostgREST v14.x (2026); a prior "limited delete" feature was removed.

Citations:


Reject unfiltered deletes on the service-role client.

If a caller omits filters, the method issues a table-wide DELETE—PostgREST sends no WHERE clause, deleting all rows the service-role has permission to delete. This is a single call-site bug causing full data loss. Supabase and PostgREST docs explicitly warn: "Always include a filter when deleting to avoid deleting all rows in the table." Require at least one filter, or force explicit opt-in with an allow_all escape hatch.

🛡️ Safer API shape
     async def delete(
-        self, table: str, filters: dict[str, Any] | None = None,
+        self,
+        table: str,
+        filters: dict[str, Any] | None = None,
+        *,
+        allow_all: bool = False,
     ) -> list[dict]:
         """DELETE rows matching eq filters. Returns deleted rows when PostgREST sends them back."""
+        if not filters and not allow_all:
+            raise ValueError("Refusing unfiltered delete on service-role client")
         params: dict[str, str] = {}
         if filters:
             for key, val in filters.items():
                 params[key] = f"eq.{val}"
         resp = await self._http.delete(f"/{table}", params=params)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/app/db.py` around lines 69 - 82, The delete method currently allows
unfiltered table-wide deletes; modify the async delete(self, table: str,
filters: dict[str, Any] | None = None) to disallow nil filters by default: if
filters is None raise a ValueError (or custom exception) with a clear message
instructing the caller to provide filters, and add an explicit opt-in parameter
(e.g., allow_all: bool = False) so callers can pass allow_all=True to
intentionally perform an unfiltered delete. Update the method docstring to state
the new behavior and mention the allow_all escape hatch, and ensure the early
check runs before making the HTTP DELETE request in _http.delete inside delete.


async def close(self):
await self._http.aclose()

Expand Down
85 changes: 84 additions & 1 deletion cloud/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from enum import Enum
from typing import Any

from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, EmailStr, Field, field_validator


class Severity(str, Enum):
Expand Down Expand Up @@ -240,3 +240,86 @@ class SubscriptionResponse(BaseModel):
status: str | None = None
current_period_end: str | None = None
usage: SubscriptionUsage = Field(default_factory=SubscriptionUsage)


# ---------------------------------------------------------------------------
# Team / workspace member models
# ---------------------------------------------------------------------------


class MemberRole(str, Enum):
owner = "owner"
admin = "admin"
member = "member"


class InviteRole(str, Enum):
admin = "admin"
member = "member"


class MemberResponse(BaseModel):
user_id: str
email: str | None = None
display_name: str | None = None
role: str
joined_at: str | None = None
last_sync_at: str | None = None


class InviteRequest(BaseModel):
email: EmailStr
role: InviteRole = InviteRole.member


class InviteResponse(BaseModel):
id: str
email: str
role: str
token: str
accept_url: str
expires_at: str | None = None


class UpdateRoleRequest(BaseModel):
role: InviteRole # owner cannot be assigned through this endpoint


# ---------------------------------------------------------------------------
# Operator / admin models (god-mode panel)
# ---------------------------------------------------------------------------


class GlobalKpis(BaseModel):
"""Aggregate KPIs across all workspaces. All monetary amounts in USD (dollars)."""

mrr_usd: float = 0.0
arr_usd: float = 0.0
mrr_delta_pct: float = 0.0 # month-over-month new-workspace growth %
customers_total: int = 0
customers_active: int = 0 # any brain synced within 14 days
churn_rate: float = 0.0 # workspaces deleted in last 30d / total at period start
net_revenue_retention: float = 1.0 # TODO: placeholder until sub-history tracked


class AdminCustomer(BaseModel):
"""One workspace row for the operator customer list."""

id: str
company: str
plan: str
mrr_usd: float = 0.0
active_users: int = 0
brains: int = 0
last_active: str | None = None # ISO timestamp
health: str = "healthy" # healthy | at-risk | churning


class AdminAlert(BaseModel):
"""A derived operational alert for the operator panel."""

id: str
kind: str # churn-risk | failed-payment | usage-spike
customer: str
detail: str
created_at: str # ISO timestamp
4 changes: 4 additions & 0 deletions cloud/app/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
from app.routes.corrections import router as corrections_router
from app.routes.lessons import router as lessons_router
from app.routes.meta_rules import router as meta_rules_router
from app.routes.operator import router as operator_router
from app.routes.rule_patches import router as rule_patches_router
from app.routes.sync import router as sync_router
from app.routes.team import router as team_router
from app.routes.users import router as users_router

router = APIRouter()
Expand All @@ -28,3 +30,5 @@
router.include_router(activity_router, tags=["activity"])
router.include_router(rule_patches_router, tags=["rule-patches"])
router.include_router(billing_router, tags=["billing"])
router.include_router(team_router, tags=["team"])
router.include_router(operator_router, tags=["operator"])
66 changes: 66 additions & 0 deletions cloud/app/routes/brains.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,69 @@ async def delete_brain(
now = datetime.now(timezone.utc).isoformat()
await db.update("brains", data={"deleted_at": now}, filters={"id": brain_id})
_log.info("Soft-deleted brain=%s", brain_id)


class ClearDemoResponse(BaseModel):
deleted: int
by_table: dict[str, int]


@router.post("/brains/{brain_id}/clear-demo", response_model=ClearDemoResponse)
async def clear_demo(
brain_id: str,
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
) -> ClearDemoResponse:
"""Delete all demo rows (is_demo=true) scoped to this brain.

Auth: caller must own the brain. Returns per-table delete counts and a total.
"""
await get_brain_for_request(brain_id, credentials)
db = get_db()

by_table: dict[str, int] = {}
total = 0

# Children first (FK order doesn't strictly matter here, but we delete
# narrowest-to-widest so counts are readable).
for table in ("corrections", "lessons", "meta_rules", "events"):
deleted = await _delete_demo_rows(db, table, brain_id)
by_table[table] = deleted
total += deleted

# Finally the brain itself — only if it was flagged is_demo in metadata.
brain_rows = await db.select(
"brains", columns="id,metadata", filters={"id": brain_id}
)
if brain_rows and _is_demo_metadata(brain_rows[0].get("metadata")):
await db.delete("brains", filters={"id": brain_id})
by_table["brains"] = 1
total += 1
else:
by_table["brains"] = 0
Comment on lines +174 to +183
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Don't delete the parent brain in a demo-only cleanup.

Line 179 drops the brains row whenever metadata.is_demo is set. If the user has created any non-demo lessons, corrections, events, or meta-rules under that brain, the FK cascade will remove those real records too. That violates the endpoint's "clear demo data" contract and can lose user data.

🛡️ Safer direction
-    if brain_rows and _is_demo_metadata(brain_rows[0].get("metadata")):
-        await db.delete("brains", filters={"id": brain_id})
-        by_table["brains"] = 1
-        total += 1
-    else:
-        by_table["brains"] = 0
+    by_table["brains"] = 0
+    if brain_rows and isinstance(brain_rows[0].get("metadata"), dict):
+        metadata = dict(brain_rows[0]["metadata"])
+        metadata.pop("is_demo", None)
+        await db.update("brains", data={"metadata": metadata}, filters={"id": brain_id})

If preserving the brain is not the intended UX, only delete it after proving there are no non-demo child rows left.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Finally the brain itself — only if it was flagged is_demo in metadata.
brain_rows = await db.select(
"brains", columns="id,metadata", filters={"id": brain_id}
)
if brain_rows and _is_demo_metadata(brain_rows[0].get("metadata")):
await db.delete("brains", filters={"id": brain_id})
by_table["brains"] = 1
total += 1
else:
by_table["brains"] = 0
# Finally the brain itself — only if it was flagged is_demo in metadata.
brain_rows = await db.select(
"brains", columns="id,metadata", filters={"id": brain_id}
)
by_table["brains"] = 0
if brain_rows and isinstance(brain_rows[0].get("metadata"), dict):
metadata = dict(brain_rows[0]["metadata"])
metadata.pop("is_demo", None)
await db.update("brains", data={"metadata": metadata}, filters={"id": brain_id})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/app/routes/brains.py` around lines 174 - 183, The current code deletes
the brain row whenever _is_demo_metadata(metadata) is true, which can
cascade-delete non-demo child rows; change the logic in the block handling
brain_rows (the select -> delete sequence) to first query each child table that
can reference brains (e.g., lessons, corrections, events, meta_rules) for
records with this brain_id that are NOT demo, and only call db.delete("brains",
filters={"id": brain_id}) if all those queries return zero non-demo rows;
otherwise do not delete the brain and set by_table["brains"]=0 (or always
preserve the brain), ensuring _is_demo_metadata is still checked before
considering deletion.


_log.info("Cleared demo data for brain=%s (deleted=%d)", brain_id, total)
return ClearDemoResponse(deleted=total, by_table=by_table)


async def _delete_demo_rows(db, table: str, brain_id: str) -> int:
"""Fetch rows for the brain, filter by is_demo marker, delete those ids."""
rows = await db.select(table, columns="id,data", filters={"brain_id": brain_id})
demo_rows = [r for r in rows if _is_demo_data(r.get("data"))]
if not demo_rows:
return 0
for row in demo_rows:
await db.delete(table, filters={"id": row["id"]})
return len(demo_rows)


def _is_demo_data(data) -> bool:
if not data:
return False
if isinstance(data, dict):
return bool(data.get("is_demo"))
return False


def _is_demo_metadata(metadata) -> bool:
return _is_demo_data(metadata)
Loading
Loading