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
21 changes: 10 additions & 11 deletions deploy/cli-minimal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,8 @@ Full platform deployment belongs to operator runbooks, not this profile.
Default startup is local-only:

```bash
SUPABASE_JWT_SECRET=... \
SUPABASE_ANON_KEY=... \
LEON_SUPABASE_SERVICE_ROLE_KEY=... \
docker compose -f deploy/cli-minimal/compose.yml up
python3 deploy/cli-minimal/generate-env.py > deploy/cli-minimal/.env
docker compose --env-file deploy/cli-minimal/.env -f deploy/cli-minimal/compose.yml up
```

That publishes `mycel-backend` on `127.0.0.1:8042`. This is the safe default
Expand All @@ -38,15 +36,16 @@ To let `cel` on another machine connect to this CLI-minimal backend, publish an
explicit reachable URL:

```bash
MYCEL_BIND_HOST=0.0.0.0 \
MYCEL_PORT=8042 \
MYCEL_PUBLIC_URL=http://<host-or-lan-ip>:8042 \
SUPABASE_JWT_SECRET=... \
SUPABASE_ANON_KEY=... \
LEON_SUPABASE_SERVICE_ROLE_KEY=... \
docker compose -f deploy/cli-minimal/compose.yml up
python3 deploy/cli-minimal/generate-env.py \
--bind-host 0.0.0.0 \
--public-url http://<host-or-lan-ip>:8042 \
> deploy/cli-minimal/.env
docker compose --env-file deploy/cli-minimal/.env -f deploy/cli-minimal/compose.yml up
```

Other machines should configure `cel` with `MYCEL_PUBLIC_URL`. Future CLI
onboarding should accept that URL, probe backend version/capabilities, and fail
loudly if the target is not a compatible Mycel communication backend.

`SUPABASE_ANON_KEY` and `LEON_SUPABASE_SERVICE_ROLE_KEY` are PostgREST JWTs
signed by `SUPABASE_JWT_SECRET`; do not replace them with opaque random strings.
52 changes: 52 additions & 0 deletions deploy/cli-minimal/generate-env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
from __future__ import annotations

import argparse
import secrets
import time
from urllib.parse import urlparse

import jwt


def _port_from_url(url: str) -> str:
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
raise SystemExit(f"--public-url must be an absolute URL: {url}")
if parsed.port is not None:
return str(parsed.port)
if parsed.scheme == "https":
return "443"
if parsed.scheme == "http":
return "80"
raise SystemExit(f"--public-url must use http or https: {url}")


def _supabase_role_token(role: str, secret: str) -> str:
now = int(time.time())
return jwt.encode({"role": role, "iss": "mycel-cli-minimal", "iat": now}, secret, algorithm="HS256")


def main() -> int:
parser = argparse.ArgumentParser(description="Generate a local Mycel CLI-minimal compose env.")
parser.add_argument("--jwt-secret", default=None, help="Supabase/PostgREST JWT secret. Random by default.")
parser.add_argument("--public-url", default="http://127.0.0.1:8042", help="URL that cel clients should connect to.")
parser.add_argument("--bind-host", default="127.0.0.1", help="Host address published by docker compose.")
args = parser.parse_args()

jwt_secret = args.jwt_secret or secrets.token_urlsafe(48)
port = _port_from_url(args.public_url)

print("# Generated by deploy/cli-minimal/generate-env.py")
print("# Keep this file local; it contains service credentials.")
print(f"SUPABASE_JWT_SECRET={jwt_secret}")
print(f"SUPABASE_ANON_KEY={_supabase_role_token('anon', jwt_secret)}")
print(f"LEON_SUPABASE_SERVICE_ROLE_KEY={_supabase_role_token('service_role', jwt_secret)}")
print(f"MYCEL_BIND_HOST={args.bind_host}")
print(f"MYCEL_PORT={port}")
print(f"MYCEL_PUBLIC_URL={args.public_url}")
return 0


if __name__ == "__main__":
raise SystemExit(main())
14 changes: 14 additions & 0 deletions scripts/apply_app_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@
""".strip()


def local_supabase_role_grants_sql(schemas: list[str]) -> str:
schema_list = ", ".join(schemas)
return f"""
grant usage on schema {schema_list} to service_role;
grant all privileges on all tables in schema {schema_list} to service_role;
grant all privileges on all sequences in schema {schema_list} to service_role;
grant all privileges on all functions in schema {schema_list} to service_role;
""".strip()


def load_manifest(path: Path = MANIFEST_PATH) -> dict[str, Any]:
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
Expand Down Expand Up @@ -95,6 +105,10 @@ def apply_schema(database_url: str, *, prepare_supabase_roles: bool = False, sch
with psycopg.connect(database_url, autocommit=True) as conn:
with conn.cursor() as cur:
cur.execute(path.read_text(encoding="utf-8"))
if prepare_supabase_roles:
with psycopg.connect(database_url, autocommit=True) as conn:
with conn.cursor() as cur:
cur.execute(local_supabase_role_grants_sql(schemas))
return files


Expand Down
14 changes: 6 additions & 8 deletions scripts/check_app_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,6 @@
CREATE_TABLE_RE = re.compile(r"^CREATE TABLE (?P<name>[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*) \(", re.MULTILINE)
CREATE_FUNCTION_RE = re.compile(r"^CREATE FUNCTION (?P<name>[a-z_][a-z0-9_]*\.[a-z_][a-z0-9_]*)\(", re.MULTILINE)

FORBIDDEN_SCHEMA_PATHS = [
SCHEMA_DIR / "local_communication.sql",
SCHEMA_DIR / "canonical",
]

FORBIDDEN_BASELINE_TEXT = [
"OWNER TO",
"GRANT ",
Expand Down Expand Up @@ -57,9 +52,12 @@ def check_schema_tree(repo_root: Path = REPO_ROOT) -> list[str]:
manifest_path = schema_dir / "app_schema_manifest.json"
violations: list[str] = []

for path in [schema_dir / "local_communication.sql", schema_dir / "canonical"]:
if path.exists():
violations.append(f"forbidden schema fork path exists: {path.relative_to(repo_root)}")
schema_subdirs = sorted(path.relative_to(repo_root).as_posix() for path in schema_dir.iterdir() if path.is_dir())
if schema_subdirs:
violations.append(f"schema directory must be flat; found subdirectories: {', '.join(schema_subdirs)}")
local_schema_path = schema_dir / "local_communication.sql"
if local_schema_path.exists():
violations.append(f"forbidden schema fork path exists: {local_schema_path.relative_to(repo_root)}")

manifest, manifest_errors = _read_manifest(manifest_path)
violations.extend(manifest_errors)
Expand Down
33 changes: 33 additions & 0 deletions tests/Unit/backend/test_local_communication_deploy.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
from __future__ import annotations

import subprocess
import sys
from pathlib import Path

import jwt
import yaml

ROOT = Path(__file__).resolve().parents[3]
SCHEMA_PATH = ROOT / "storage/schema/local_communication.sql"
CLI_MINIMAL_COMPOSE = ROOT / "deploy/cli-minimal/compose.yml"
CLI_MINIMAL_GATEWAY_CONF = ROOT / "deploy/cli-minimal/rest-gateway.conf"
CLI_MINIMAL_ENV_SCRIPT = ROOT / "deploy/cli-minimal/generate-env.py"
DEPLOY_README = ROOT / "deploy/README.md"


Expand Down Expand Up @@ -79,6 +83,35 @@ def test_cli_minimal_gateway_is_nginx_rest_only_not_supabase_bundle() -> None:
assert not {"gotrue", "studio", "realtime", "storage", "analytics", "kong"} & set(services)


def test_cli_minimal_env_generator_emits_postgrest_compatible_jwts() -> None:
result = subprocess.run(
[
sys.executable,
str(CLI_MINIMAL_ENV_SCRIPT),
"--jwt-secret",
"test-secret",
"--public-url",
"http://127.0.0.1:18442",
],
cwd=ROOT,
text=True,
capture_output=True,
check=False,
)

assert result.returncode == 0, result.stderr
env = dict(line.split("=", 1) for line in result.stdout.splitlines() if line and not line.startswith("#"))

assert env["SUPABASE_JWT_SECRET"] == "test-secret"
assert env["MYCEL_PUBLIC_URL"] == "http://127.0.0.1:18442"
assert env["MYCEL_PORT"] == "18442"
assert jwt.decode(env["SUPABASE_ANON_KEY"], "test-secret", algorithms=["HS256"], options={"verify_aud": False})["role"] == "anon"
assert (
jwt.decode(env["LEON_SUPABASE_SERVICE_ROLE_KEY"], "test-secret", algorithms=["HS256"], options={"verify_aud": False})["role"]
== "service_role"
)


def test_deploy_readme_names_cli_minimal_boundary_and_full_deploy_absence() -> None:
text = DEPLOY_README.read_text(encoding="utf-8")

Expand Down
12 changes: 12 additions & 0 deletions tests/Unit/storage/test_app_schema_applier.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ def test_supabase_role_prelude_is_explicit() -> None:
assert "create role anon" in applier.SUPABASE_ROLE_PRELUDE


def test_local_supabase_role_grants_cover_all_app_owned_schemas() -> None:
applier = _load_applier()

sql = applier.local_supabase_role_grants_sql(["identity", "chat"])

assert "grant usage on schema identity, chat to service_role" in sql
assert "grant all privileges on all tables in schema identity, chat to service_role" in sql
assert "grant all privileges on all sequences in schema identity, chat to service_role" in sql
assert "grant all privileges on all functions in schema identity, chat to service_role" in sql


def test_applier_isolates_session_state_between_schema_files(tmp_path, monkeypatch) -> None:
applier = _load_applier()
schema_dir = tmp_path / "schema"
Expand Down Expand Up @@ -133,6 +144,7 @@ def cursor(self) -> Cursor:
[applier.APP_SCHEMA_STATE_SQL],
["select set_config('search_path', '', false);"],
["create extension if not exists pgcrypto;"],
[applier.local_supabase_role_grants_sql(["identity"])],
]


Expand Down
17 changes: 17 additions & 0 deletions tests/Unit/storage/test_app_schema_discipline.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,23 @@ def test_app_schema_checker_passes_current_tree() -> None:
assert checker.check_schema_tree(ROOT) == []


def test_app_schema_checker_rejects_schema_subdirectories(tmp_path) -> None:
checker = _load_checker()
schema_dir = tmp_path / "storage" / "schema"
schema_dir.mkdir(parents=True)
(schema_dir / "nested").mkdir()
(schema_dir / "app_schema_manifest.json").write_text(
'{"baseline": "app_schema.sql", "patches": [], "app_owned_schemas": ["identity"], '
'"baseline_table_count": 0, "baseline_function_count": 0}',
encoding="utf-8",
)
(schema_dir / "app_schema.sql").write_text("CREATE SCHEMA identity;", encoding="utf-8")

violations = checker.check_schema_tree(tmp_path)

assert violations == ["schema directory must be flat; found subdirectories: storage/schema/nested"]


def test_app_schema_checker_is_a_loud_cli_gate() -> None:
result = subprocess.run(
[sys.executable, str(CHECKER_PATH)],
Expand Down
Loading