From a8593afa61673f0e78edbd7652a6670090d2109d Mon Sep 17 00:00:00 2001 From: Gerhard Scheikl Date: Fri, 29 May 2026 17:12:19 +0200 Subject: [PATCH] initial commit --- .env | 2 + .gitignore | 7 + README.md | 51 +++ bandwidth-worker/Dockerfile | 8 + bandwidth-worker/docker-compose.yml | 13 + bandwidth-worker/requirements.txt | 2 + bandwidth-worker/worker.py | 145 +++++++++ caddy/Caddyfile | 46 +++ caddy/docker-compose.yml | 18 ++ frps/docker-compose.yml | 16 + redis/docker-compose.yml | 28 ++ stripe-stub/.env.template | 3 + stripe-stub/Dockerfile | 9 + stripe-stub/app.py | 45 +++ stripe-stub/docker-compose.yml | 20 ++ stripe-stub/requirements.txt | 3 + supabase/.env.example | 113 +++++++ supabase/.gitignore | 5 + supabase/README.md | 3 + supabase/dev/data.sql | 48 +++ supabase/dev/docker-compose.dev.yml | 34 ++ supabase/docker-compose.s3.yml | 96 ++++++ supabase/docker-compose.yml | 479 ++++++++++++++++++++++++++++ supabase/migrations/0001_init.sql | 128 ++++++++ 24 files changed, 1322 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bandwidth-worker/Dockerfile create mode 100644 bandwidth-worker/docker-compose.yml create mode 100644 bandwidth-worker/requirements.txt create mode 100644 bandwidth-worker/worker.py create mode 100644 caddy/Caddyfile create mode 100644 caddy/docker-compose.yml create mode 100644 frps/docker-compose.yml create mode 100644 redis/docker-compose.yml create mode 100644 stripe-stub/.env.template create mode 100644 stripe-stub/Dockerfile create mode 100644 stripe-stub/app.py create mode 100644 stripe-stub/docker-compose.yml create mode 100644 stripe-stub/requirements.txt create mode 100644 supabase/.env.example create mode 100644 supabase/.gitignore create mode 100644 supabase/README.md create mode 100644 supabase/dev/data.sql create mode 100644 supabase/dev/docker-compose.dev.yml create mode 100644 supabase/docker-compose.s3.yml create mode 100644 supabase/docker-compose.yml create mode 100644 supabase/migrations/0001_init.sql diff --git a/.env b/.env new file mode 100644 index 0000000..e3e3e1b --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +DOMAIN=linumiq.net +LE_EMAIL=office@linumiq.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..463fdd5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/caddy/data/ +/caddy/config/caddy/autosave.json +/redis/data/ +/supabase/volumes/ +/web/ +/SECRETS.md +.env \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..149b9d0 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# /docker — linumiq.net remote access stack + +## Networks +Shared external docker network: `edge` (created with `docker network create edge`). + +## Shared env +`/docker/.env` — DOMAIN, LE_EMAIL. + +## Per-service secrets +`/docker//.env`, all `chmod 600`, owned `root:root`. See `/docker/SECRETS.md` +for the inventory. + +## Service inventory (Wave A + Wave B) + +| Service | Compose dir | Container name | Listens | Image | +|-------------------|------------------------------|--------------------------|----------------------------------|--------------------------------------| +| Supabase stack | /docker/supabase | supabase-* | kong :8000, edge :9000 (internal)| supabase/* (pinned per compose) | +| Caddy | /docker/caddy | caddy | :80, :443 | caddy:2.10.2-alpine | +| frps | /docker/frps | frps | :7000 ctrl, :7080 vhost, :7500 dash | snowdreamtech/frps:0.65.0 | +| Redis | /docker/redis | redis | :6379 (edge net only) | redis:7.2-alpine | +| stripe-stub | /docker/stripe-stub | stripe-stub | 127.0.0.1:4242 | stripe-stub:1.0.0 (local build) | +| bandwidth-worker | /docker/bandwidth-worker | bandwidth-worker | (no inbound) | bandwidth-worker:1.0.0 (local build) | + +## Start order +1. `cd /docker/redis && docker compose --env-file .env up -d` +2. `cd /docker/supabase && docker compose up -d` +3. `cd /docker/frps && docker compose up -d` +4. `cd /docker/caddy && docker compose up -d` +5. `cd /docker/stripe-stub && docker compose up -d` +6. `cd /docker/bandwidth-worker && docker compose up -d` +7. (later) `cd /docker/web && docker compose up -d` + +## Stop order +Reverse of the above. `docker compose down` per directory. + +## Edge functions +Mounted from `/docker/supabase/volumes/functions/`. The `main` function is a +router that reads the first URL path segment as the function name. +- Via Kong: `POST http://127.0.0.1:8000/functions/v1/` +- Direct (intra-`edge` network, e.g. frps auth plugin): + `POST http://supabase-edge-functions:9000/` + +After editing a function, `cd /docker/supabase && docker compose restart functions` +(or `up -d functions` if env changed). + +## Wave B specifics +- `frps.toml` enables `[[httpPlugins]] name="auth"` pointing at + `http://supabase-edge-functions:9000/auth-webhook`. +- `supabase-edge-functions` reads `REDIS_URL` from `/docker/supabase/.env`. +- bandwidth-worker polls `http://frps:7500/api/proxy/http` every 60s; deltas go + to `public.usage_samples` and `public.tunnels.bytes_used` via PostgREST. diff --git a/bandwidth-worker/Dockerfile b/bandwidth-worker/Dockerfile new file mode 100644 index 0000000..9a132ee --- /dev/null +++ b/bandwidth-worker/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY worker.py . + +CMD ["python", "-u", "worker.py"] diff --git a/bandwidth-worker/docker-compose.yml b/bandwidth-worker/docker-compose.yml new file mode 100644 index 0000000..ae20451 --- /dev/null +++ b/bandwidth-worker/docker-compose.yml @@ -0,0 +1,13 @@ +services: + bandwidth-worker: + build: . + image: bandwidth-worker:1.0.0 + container_name: bandwidth-worker + restart: unless-stopped + env_file: .env + networks: + - edge + +networks: + edge: + external: true diff --git a/bandwidth-worker/requirements.txt b/bandwidth-worker/requirements.txt new file mode 100644 index 0000000..07ece12 --- /dev/null +++ b/bandwidth-worker/requirements.txt @@ -0,0 +1,2 @@ +httpx==0.27.2 +redis==5.1.1 diff --git a/bandwidth-worker/worker.py b/bandwidth-worker/worker.py new file mode 100644 index 0000000..db1739c --- /dev/null +++ b/bandwidth-worker/worker.py @@ -0,0 +1,145 @@ +"""bandwidth-worker: polls frps dashboard, writes deltas to Postgres via PostgREST.""" + +from __future__ import annotations + +import logging +import os +import sys +import time +from datetime import datetime, timezone +from typing import Any + +import httpx +import redis + +POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL_SECONDS", "60")) +FRPS_API = os.environ.get("FRPS_API_URL", "http://frps:7500/api/proxy/http") +FRPS_USER = os.environ["FRPS_DASHBOARD_USER"] +FRPS_PASS = os.environ["FRPS_DASHBOARD_PASS"] +SUPABASE_URL = os.environ.get("SUPABASE_URL", "http://supabase-kong:8000") +SERVICE_ROLE = os.environ["SUPABASE_SERVICE_ROLE_KEY"] +REDIS_URL = os.environ["REDIS_URL"] + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + stream=sys.stdout, +) +log = logging.getLogger("bandwidth-worker") + +rds = redis.from_url(REDIS_URL, decode_responses=True) +last_seen: dict[str, int] = {} + + +def _headers(extra: dict[str, str] | None = None) -> dict[str, str]: + h = { + "apikey": SERVICE_ROLE, + "authorization": f"Bearer {SERVICE_ROLE}", + "content-type": "application/json", + } + if extra: + h.update(extra) + return h + + +def fetch_proxies(client: httpx.Client) -> list[dict[str, Any]]: + resp = client.get(FRPS_API, auth=(FRPS_USER, FRPS_PASS), timeout=10.0) + resp.raise_for_status() + return resp.json().get("proxies") or [] + + +def previous_total(subdomain: str) -> int: + if subdomain in last_seen: + return last_seen[subdomain] + try: + v = rds.get(f"tunnel:bytes_total:{subdomain}") + if v is not None: + return int(v) + except Exception as e: + log.warning("redis get failed for %s: %s", subdomain, e) + return 0 + + +def persist_total(subdomain: str, total: int) -> None: + last_seen[subdomain] = total + try: + rds.set(f"tunnel:bytes_total:{subdomain}", total) + except Exception as e: + log.warning("redis set failed for %s: %s", subdomain, e) + + +def record_delta(client: httpx.Client, subdomain: str, delta: int) -> None: + r = client.post( + f"{SUPABASE_URL}/rest/v1/usage_samples", + headers=_headers({"prefer": "return=minimal"}), + json={"subdomain": subdomain, "bytes_delta": delta}, + timeout=10.0, + ) + if r.status_code >= 300: + log.error("usage_samples insert failed %s: %s", r.status_code, r.text) + return + + cur = client.get( + f"{SUPABASE_URL}/rest/v1/tunnels?select=bytes_used&subdomain=eq.{subdomain}", + headers=_headers(), + timeout=10.0, + ) + if cur.status_code >= 300: + log.error("tunnels select failed %s: %s", cur.status_code, cur.text) + return + rows = cur.json() + if not rows: + log.warning("tunnel row missing for subdomain=%s; sample retained", subdomain) + return + current = int(rows[0].get("bytes_used") or 0) + upd = client.patch( + f"{SUPABASE_URL}/rest/v1/tunnels?subdomain=eq.{subdomain}", + headers=_headers({"prefer": "return=minimal"}), + json={ + "bytes_used": current + delta, + "last_seen_at": datetime.now(timezone.utc).isoformat(), + }, + timeout=10.0, + ) + if upd.status_code >= 300: + log.error("tunnels update failed %s: %s", upd.status_code, upd.text) + + +def poll_once(client: httpx.Client) -> None: + proxies = fetch_proxies(client) + log.info("poll: %d proxies", len(proxies)) + for p in proxies: + name = p.get("name") or "" + if not name: + continue + traffic_in = int(p.get("today_traffic_in") or 0) + traffic_out = int(p.get("today_traffic_out") or 0) + total = traffic_in + traffic_out + prev = previous_total(name) + delta = total - prev + if delta < 0: + delta = total # daily counter reset + if delta == 0: + persist_total(name, total) + continue + try: + record_delta(client, name, delta) + persist_total(name, total) + log.info("recorded delta=%d for %s (total=%d)", delta, name, total) + except Exception as e: + log.error("record_delta failed for %s: %s", name, e) + + +def main() -> None: + log.info("bandwidth-worker starting; interval=%ss api=%s", POLL_INTERVAL, FRPS_API) + with httpx.Client() as client: + while True: + try: + poll_once(client) + except Exception as e: + log.error("poll failed: %s", e) + time.sleep(POLL_INTERVAL) + + +if __name__ == "__main__": + main() diff --git a/caddy/Caddyfile b/caddy/Caddyfile new file mode 100644 index 0000000..c9ece24 --- /dev/null +++ b/caddy/Caddyfile @@ -0,0 +1,46 @@ +{ + email office@linumiq.com + on_demand_tls { + # Self-hosted ask endpoint on :9999 (always 200 in Wave A). + # TODO Wave B: point ask at https://api.linumiq.net/functions/v1/check-subdomain + # (an Edge Function that returns 200 only for subdomains present in tunnels table). + ask http://localhost:9999/check + } +} + +# Apex -> dashboard redirect +linumiq.net { + tls { + on_demand + } + redir https://app.linumiq.net{uri} permanent +} + +# Reserved hostname: Next.js dashboard (upstream not yet running in Wave A) +app.linumiq.net { + tls { + on_demand + } + reverse_proxy web:3000 +} + +# Reserved hostname: Supabase API (Kong) +api.linumiq.net { + tls { + on_demand + } + reverse_proxy supabase-kong:8000 +} + +# Wildcard tunnel subdomains -> frps vhost HTTP. Per-name HTTP-01 issued on first hit. +*.linumiq.net { + tls { + on_demand + } + reverse_proxy frps:7080 +} + +# Internal ask endpoint for on-demand TLS. Bound to loopback inside the container. +http://localhost:9999 { + respond /check 200 +} diff --git a/caddy/docker-compose.yml b/caddy/docker-compose.yml new file mode 100644 index 0000000..7107db5 --- /dev/null +++ b/caddy/docker-compose.yml @@ -0,0 +1,18 @@ +services: + caddy: + image: caddy:2.10.2-alpine + container_name: caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - ./data:/data + - ./config:/config + networks: + - edge +networks: + edge: + external: true diff --git a/frps/docker-compose.yml b/frps/docker-compose.yml new file mode 100644 index 0000000..d91c8b6 --- /dev/null +++ b/frps/docker-compose.yml @@ -0,0 +1,16 @@ +services: + frps: + image: snowdreamtech/frps:0.65.0 + container_name: frps + restart: unless-stopped + ports: + - "7000:7000" + - "7500:7500" + volumes: + - ./frps.toml:/etc/frp/frps.toml:ro + command: ["frps", "-c", "/etc/frp/frps.toml"] + networks: + - edge +networks: + edge: + external: true diff --git a/redis/docker-compose.yml b/redis/docker-compose.yml new file mode 100644 index 0000000..2be93c5 --- /dev/null +++ b/redis/docker-compose.yml @@ -0,0 +1,28 @@ +services: + redis: + image: redis:7.2-alpine + container_name: redis + restart: unless-stopped + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD} + command: + - redis-server + - --requirepass + - ${REDIS_PASSWORD} + - --appendonly + - "yes" + - --appendfsync + - everysec + volumes: + - ./data:/data + healthcheck: + test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" PING | grep -q PONG"] + interval: 10s + timeout: 3s + retries: 5 + networks: + - edge + +networks: + edge: + external: true diff --git a/stripe-stub/.env.template b/stripe-stub/.env.template new file mode 100644 index 0000000..21696a8 --- /dev/null +++ b/stripe-stub/.env.template @@ -0,0 +1,3 @@ +DOMAIN=linumiq.net +EDGE_STRIPE_WEBHOOK_URL=http://supabase-edge-functions:9000/stripe-webhook +STRIPE_STUB_WEBHOOK_SECRET=__SECRET__ diff --git a/stripe-stub/Dockerfile b/stripe-stub/Dockerfile new file mode 100644 index 0000000..c1a99e0 --- /dev/null +++ b/stripe-stub/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app.py . + +EXPOSE 4242 +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "4242"] diff --git a/stripe-stub/app.py b/stripe-stub/app.py new file mode 100644 index 0000000..94217ed --- /dev/null +++ b/stripe-stub/app.py @@ -0,0 +1,45 @@ +"""stripe-stub: pretends every checkout succeeds and forwards test webhooks.""" + +import os +import uuid +from typing import Any + +import httpx +from fastapi import FastAPI, Request + +DOMAIN = os.environ.get("DOMAIN", "linumiq.net") +EDGE_URL = os.environ.get( + "EDGE_STRIPE_WEBHOOK_URL", + "http://supabase-edge-functions:9000/stripe-webhook", +) + +app = FastAPI() + + +@app.post("/v1/checkout/sessions") +async def create_session(_request: Request) -> dict[str, str]: + session_id = f"stub_{uuid.uuid4().hex}" + return { + "id": session_id, + "url": f"https://app.{DOMAIN}/billing/success?session={session_id}", + } + + +@app.post("/v1/webhooks/test") +async def forward_test(request: Request) -> Any: + body = await request.body() + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post( + EDGE_URL, + content=body, + headers={"content-type": request.headers.get("content-type", "application/json")}, + ) + try: + return resp.json() + except Exception: + return {"upstream_status": resp.status_code, "body": resp.text} + + +@app.get("/health") +def health() -> dict[str, str]: + return {"status": "ok"} diff --git a/stripe-stub/docker-compose.yml b/stripe-stub/docker-compose.yml new file mode 100644 index 0000000..4f913e4 --- /dev/null +++ b/stripe-stub/docker-compose.yml @@ -0,0 +1,20 @@ +services: + stripe-stub: + build: . + image: stripe-stub:1.0.0 + container_name: stripe-stub + restart: unless-stopped + env_file: .env + networks: + - edge + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:4242/health').status==200 else 1)"] + interval: 30s + timeout: 5s + retries: 3 + ports: + - "127.0.0.1:4242:4242" + +networks: + edge: + external: true diff --git a/stripe-stub/requirements.txt b/stripe-stub/requirements.txt new file mode 100644 index 0000000..48a7f46 --- /dev/null +++ b/stripe-stub/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.4 +uvicorn[standard]==0.32.0 +httpx==0.27.2 diff --git a/supabase/.env.example b/supabase/.env.example new file mode 100644 index 0000000..fb00d7d --- /dev/null +++ b/supabase/.env.example @@ -0,0 +1,113 @@ +############ +# Secrets +# YOU MUST CHANGE THESE BEFORE GOING INTO PRODUCTION +############ + +POSTGRES_PASSWORD=your-super-secret-and-long-postgres-password +JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters-long +ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE +SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q +DASHBOARD_USERNAME=supabase +DASHBOARD_PASSWORD=this_password_is_insecure_and_should_be_updated + +############ +# Database - You can change these to any PostgreSQL database that has logical replication enabled. +############ + +POSTGRES_HOST=db +POSTGRES_DB=postgres +POSTGRES_PORT=5432 +# default user is postgres + +############ +# Supavisor -- Database pooler +############ +POOLER_PROXY_PORT_TRANSACTION=6543 +POOLER_DEFAULT_POOL_SIZE=20 +POOLER_MAX_CLIENT_CONN=100 + + +############ +# API Proxy - Configuration for the Kong Reverse proxy. +############ + +KONG_HTTP_PORT=8000 +KONG_HTTPS_PORT=8443 + + +############ +# API - Configuration for PostgREST. +############ + +PGRST_DB_SCHEMAS=public,storage,graphql_public + + +############ +# Auth - Configuration for the GoTrue authentication server. +############ + +## General +SITE_URL=http://localhost:3000 +ADDITIONAL_REDIRECT_URLS= +JWT_EXPIRY=3600 +DISABLE_SIGNUP=false +API_EXTERNAL_URL=http://localhost:8000 + +## Mailer Config +MAILER_URLPATHS_CONFIRMATION="/auth/v1/verify" +MAILER_URLPATHS_INVITE="/auth/v1/verify" +MAILER_URLPATHS_RECOVERY="/auth/v1/verify" +MAILER_URLPATHS_EMAIL_CHANGE="/auth/v1/verify" + +## Email auth +ENABLE_EMAIL_SIGNUP=true +ENABLE_EMAIL_AUTOCONFIRM=false +SMTP_ADMIN_EMAIL=admin@example.com +SMTP_HOST=supabase-mail +SMTP_PORT=2500 +SMTP_USER=fake_mail_user +SMTP_PASS=fake_mail_password +SMTP_SENDER_NAME=fake_sender +ENABLE_ANONYMOUS_USERS=false + +## Phone auth +ENABLE_PHONE_SIGNUP=true +ENABLE_PHONE_AUTOCONFIRM=true + + +############ +# Studio - Configuration for the Dashboard +############ + +STUDIO_DEFAULT_ORGANIZATION=Default Organization +STUDIO_DEFAULT_PROJECT=Default Project + +STUDIO_PORT=3000 +# replace if you intend to use Studio outside of localhost +SUPABASE_PUBLIC_URL=http://localhost:8000 + +# Enable webp support +IMGPROXY_ENABLE_WEBP_DETECTION=true + +############ +# Functions - Configuration for Functions +############ +# NOTE: VERIFY_JWT applies to all functions. Per-function VERIFY_JWT is not supported yet. +FUNCTIONS_VERIFY_JWT=false + +############ +# Logs - Configuration for Logflare +# Please refer to https://supabase.com/docs/reference/self-hosting-analytics/introduction +############ + +LOGFLARE_LOGGER_BACKEND_API_KEY=your-super-secret-and-long-logflare-key + +# Change vector.toml sinks to reflect this change +LOGFLARE_API_KEY=your-super-secret-and-long-logflare-key + +# Docker socket location - this value will differ depending on your OS +DOCKER_SOCKET_LOCATION=/var/run/docker.sock + +# Google Cloud Project details +GOOGLE_PROJECT_ID=GOOGLE_PROJECT_ID +GOOGLE_PROJECT_NUMBER=GOOGLE_PROJECT_NUMBER diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 0000000..a1e9dc6 --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,5 @@ +volumes/db/data +volumes/storage +.env +test.http +docker-compose.override.yml diff --git a/supabase/README.md b/supabase/README.md new file mode 100644 index 0000000..9ab215b --- /dev/null +++ b/supabase/README.md @@ -0,0 +1,3 @@ +# Supabase Docker + +This is a minimal Docker Compose setup for self-hosting Supabase. Follow the steps [here](https://supabase.com/docs/guides/hosting/docker) to get started. diff --git a/supabase/dev/data.sql b/supabase/dev/data.sql new file mode 100644 index 0000000..2328004 --- /dev/null +++ b/supabase/dev/data.sql @@ -0,0 +1,48 @@ +create table profiles ( + id uuid references auth.users not null, + updated_at timestamp with time zone, + username text unique, + avatar_url text, + website text, + + primary key (id), + unique(username), + constraint username_length check (char_length(username) >= 3) +); + +alter table profiles enable row level security; + +create policy "Public profiles are viewable by the owner." + on profiles for select + using ( auth.uid() = id ); + +create policy "Users can insert their own profile." + on profiles for insert + with check ( auth.uid() = id ); + +create policy "Users can update own profile." + on profiles for update + using ( auth.uid() = id ); + +-- Set up Realtime +begin; + drop publication if exists supabase_realtime; + create publication supabase_realtime; +commit; +alter publication supabase_realtime add table profiles; + +-- Set up Storage +insert into storage.buckets (id, name) +values ('avatars', 'avatars'); + +create policy "Avatar images are publicly accessible." + on storage.objects for select + using ( bucket_id = 'avatars' ); + +create policy "Anyone can upload an avatar." + on storage.objects for insert + with check ( bucket_id = 'avatars' ); + +create policy "Anyone can update an avatar." + on storage.objects for update + with check ( bucket_id = 'avatars' ); diff --git a/supabase/dev/docker-compose.dev.yml b/supabase/dev/docker-compose.dev.yml new file mode 100644 index 0000000..ca19a0a --- /dev/null +++ b/supabase/dev/docker-compose.dev.yml @@ -0,0 +1,34 @@ +version: "3.8" + +services: + studio: + build: + context: .. + dockerfile: studio/Dockerfile + target: dev + ports: + - 8082:8082 + mail: + container_name: supabase-mail + image: inbucket/inbucket:3.0.3 + ports: + - '2500:2500' # SMTP + - '9000:9000' # web interface + - '1100:1100' # POP3 + auth: + environment: + - GOTRUE_SMTP_USER= + - GOTRUE_SMTP_PASS= + meta: + ports: + - 5555:8080 + db: + restart: 'no' + volumes: + # Always use a fresh database when developing + - /var/lib/postgresql/data + # Seed data should be inserted last (alphabetical order) + - ./dev/data.sql:/docker-entrypoint-initdb.d/seed.sql + storage: + volumes: + - /var/lib/storage diff --git a/supabase/docker-compose.s3.yml b/supabase/docker-compose.s3.yml new file mode 100644 index 0000000..bb8be62 --- /dev/null +++ b/supabase/docker-compose.s3.yml @@ -0,0 +1,96 @@ +version: "3.8" + +services: + + minio: + image: minio/minio + ports: + - '9000:9000' + - '9001:9001' + environment: + MINIO_ROOT_USER: supa-storage + MINIO_ROOT_PASSWORD: secret1234 + command: server --console-address ":9001" /data + healthcheck: + test: [ "CMD", "curl", "-f", "http://minio:9000/minio/health/live" ] + interval: 2s + timeout: 10s + retries: 5 + volumes: + - ./volumes/storage:/data:z + + minio-createbucket: + image: minio/mc + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + /usr/bin/mc alias set supa-minio http://minio:9000 supa-storage secret1234; + /usr/bin/mc mb supa-minio/stub; + exit 0; + " + + storage: + container_name: supabase-storage + image: supabase/storage-api:v0.43.11 + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + rest: + condition: service_started + imgproxy: + condition: service_started + minio: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://localhost:5000/status" + ] + timeout: 5s + interval: 5s + retries: 3 + restart: unless-stopped + environment: + ANON_KEY: ${ANON_KEY} + SERVICE_KEY: ${SERVICE_ROLE_KEY} + POSTGREST_URL: http://rest:3000 + PGRST_JWT_SECRET: ${JWT_SECRET} + DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + FILE_SIZE_LIMIT: 52428800 + STORAGE_BACKEND: s3 + GLOBAL_S3_BUCKET: stub + GLOBAL_S3_ENDPOINT: http://minio:9000 + GLOBAL_S3_PROTOCOL: http + GLOBAL_S3_FORCE_PATH_STYLE: true + AWS_ACCESS_KEY_ID: supa-storage + AWS_SECRET_ACCESS_KEY: secret1234 + AWS_DEFAULT_REGION: stub + FILE_STORAGE_BACKEND_PATH: /var/lib/storage + TENANT_ID: stub + # TODO: https://github.com/supabase/storage-api/issues/55 + REGION: stub + ENABLE_IMAGE_TRANSFORMATION: "true" + IMGPROXY_URL: http://imgproxy:5001 + volumes: + - ./volumes/storage:/var/lib/storage:z + + imgproxy: + container_name: supabase-imgproxy + image: darthsim/imgproxy:v3.8.0 + healthcheck: + test: [ "CMD", "imgproxy", "health" ] + timeout: 5s + interval: 5s + retries: 3 + environment: + IMGPROXY_BIND: ":5001" + IMGPROXY_USE_ETAG: "true" + IMGPROXY_ENABLE_WEBP_DETECTION: ${IMGPROXY_ENABLE_WEBP_DETECTION} diff --git a/supabase/docker-compose.yml b/supabase/docker-compose.yml new file mode 100644 index 0000000..0d45d6d --- /dev/null +++ b/supabase/docker-compose.yml @@ -0,0 +1,479 @@ +# Usage +# Start: docker compose up +# With helpers: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml up +# Stop: docker compose down +# Destroy: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml down -v --remove-orphans + +name: supabase + +services: + studio: + container_name: supabase-studio + image: supabase/studio:20240923-2e3e90c + restart: unless-stopped + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "require('http').get('http://localhost:3000/api/profile', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})" + ] + timeout: 5s + interval: 5s + retries: 3 + depends_on: + analytics: + condition: service_healthy + environment: + STUDIO_PG_META_URL: http://meta:8080 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + + DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION} + DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT} + + SUPABASE_URL: http://kong:8000 + SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL} + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} + AUTH_JWT_SECRET: ${JWT_SECRET} + + LOGFLARE_API_KEY: ${LOGFLARE_API_KEY} + LOGFLARE_URL: http://analytics:4000 + NEXT_PUBLIC_ENABLE_LOGS: true + # Comment to use Big Query backend for analytics + NEXT_ANALYTICS_BACKEND_PROVIDER: postgres + # Uncomment to use Big Query backend for analytics + # NEXT_ANALYTICS_BACKEND_PROVIDER: bigquery + + kong: + container_name: supabase-kong + image: kong:2.8.1 + restart: unless-stopped + # https://unix.stackexchange.com/a/294837 + entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start' + ports: + - ${KONG_HTTP_PORT}:8000/tcp + - ${KONG_HTTPS_PORT}:8443/tcp + depends_on: + analytics: + condition: service_healthy + environment: + KONG_DATABASE: "off" + KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml + # https://github.com/supabase/cli/issues/14 + KONG_DNS_ORDER: LAST,A,CNAME + KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth + KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k + KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} + DASHBOARD_USERNAME: ${DASHBOARD_USERNAME} + DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD} + volumes: + # https://github.com/supabase/supabase/issues/12661 + - ./volumes/api/kong.yml:/home/kong/temp.yml:ro + + auth: + container_name: supabase-auth + image: supabase/gotrue:v2.158.1 + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + analytics: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://localhost:9999/health" + ] + timeout: 5s + interval: 5s + retries: 3 + restart: unless-stopped + environment: + GOTRUE_API_HOST: 0.0.0.0 + GOTRUE_API_PORT: 9999 + API_EXTERNAL_URL: ${API_EXTERNAL_URL} + + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + + GOTRUE_SITE_URL: ${SITE_URL} + GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS} + GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP} + + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_EXP: ${JWT_EXPIRY} + GOTRUE_JWT_SECRET: ${JWT_SECRET} + + GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP} + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS} + GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM} + # GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: true + # GOTRUE_SMTP_MAX_FREQUENCY: 1s + GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL} + GOTRUE_SMTP_HOST: ${SMTP_HOST} + GOTRUE_SMTP_PORT: ${SMTP_PORT} + GOTRUE_SMTP_USER: ${SMTP_USER} + GOTRUE_SMTP_PASS: ${SMTP_PASS} + GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME} + GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE} + GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION} + GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY} + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE} + + GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP} + GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM} + # Uncomment to enable custom access token hook. Please see: https://supabase.com/docs/guides/auth/auth-hooks for full list of hooks and additional details about custom_access_token_hook + + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: "true" + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI: "pg-functions://postgres/public/custom_access_token_hook" + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS: "" + + # GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_ENABLED: "true" + # GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/mfa_verification_attempt" + + # GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED: "true" + # GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/password_verification_attempt" + + # GOTRUE_HOOK_SEND_SMS_ENABLED: "false" + # GOTRUE_HOOK_SEND_SMS_URI: "pg-functions://postgres/public/custom_access_token_hook" + # GOTRUE_HOOK_SEND_SMS_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n" + + # GOTRUE_HOOK_SEND_EMAIL_ENABLED: "false" + # GOTRUE_HOOK_SEND_EMAIL_URI: "http://host.docker.internal:54321/functions/v1/email_sender" + # GOTRUE_HOOK_SEND_EMAIL_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n" + + + + + + rest: + container_name: supabase-rest + image: postgrest/postgrest:v12.2.0 + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + analytics: + condition: service_healthy + restart: unless-stopped + environment: + PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS} + PGRST_DB_ANON_ROLE: anon + PGRST_JWT_SECRET: ${JWT_SECRET} + PGRST_DB_USE_LEGACY_GUCS: "false" + PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET} + PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY} + command: "postgrest" + + realtime: + # This container name looks inconsistent but is correct because realtime constructs tenant id by parsing the subdomain + container_name: realtime-dev.supabase-realtime + image: supabase/realtime:v2.30.34 + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + analytics: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "curl", + "-sSfL", + "--head", + "-o", + "/dev/null", + "-H", + "Authorization: Bearer ${ANON_KEY}", + "http://localhost:4000/api/tenants/realtime-dev/health" + ] + timeout: 5s + interval: 5s + retries: 3 + restart: unless-stopped + environment: + PORT: 4000 + DB_HOST: ${POSTGRES_HOST} + DB_PORT: ${POSTGRES_PORT} + DB_USER: supabase_admin + DB_PASSWORD: ${POSTGRES_PASSWORD} + DB_NAME: ${POSTGRES_DB} + DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' + DB_ENC_KEY: supabaserealtime + API_JWT_SECRET: ${JWT_SECRET} + SECRET_KEY_BASE: UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq + ERL_AFLAGS: -proto_dist inet_tcp + DNS_NODES: "''" + RLIMIT_NOFILE: "10000" + APP_NAME: realtime + SEED_SELF_HOST: true + + # To use S3 backed storage: docker compose -f docker-compose.yml -f docker-compose.s3.yml up + storage: + container_name: supabase-storage + image: supabase/storage-api:v1.10.1 + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + rest: + condition: service_started + imgproxy: + condition: service_started + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://localhost:5000/status" + ] + timeout: 5s + interval: 5s + retries: 3 + restart: unless-stopped + environment: + ANON_KEY: ${ANON_KEY} + SERVICE_KEY: ${SERVICE_ROLE_KEY} + POSTGREST_URL: http://rest:3000 + PGRST_JWT_SECRET: ${JWT_SECRET} + DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + FILE_SIZE_LIMIT: 52428800 + STORAGE_BACKEND: file + FILE_STORAGE_BACKEND_PATH: /var/lib/storage + TENANT_ID: stub + # TODO: https://github.com/supabase/storage-api/issues/55 + REGION: stub + GLOBAL_S3_BUCKET: stub + ENABLE_IMAGE_TRANSFORMATION: "true" + IMGPROXY_URL: http://imgproxy:5001 + volumes: + - ./volumes/storage:/var/lib/storage:z + + imgproxy: + container_name: supabase-imgproxy + image: darthsim/imgproxy:v3.8.0 + healthcheck: + test: [ "CMD", "imgproxy", "health" ] + timeout: 5s + interval: 5s + retries: 3 + environment: + IMGPROXY_BIND: ":5001" + IMGPROXY_LOCAL_FILESYSTEM_ROOT: / + IMGPROXY_USE_ETAG: "true" + IMGPROXY_ENABLE_WEBP_DETECTION: ${IMGPROXY_ENABLE_WEBP_DETECTION} + volumes: + - ./volumes/storage:/var/lib/storage:z + + meta: + container_name: supabase-meta + image: supabase/postgres-meta:v0.83.2 + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + analytics: + condition: service_healthy + restart: unless-stopped + environment: + PG_META_PORT: 8080 + PG_META_DB_HOST: ${POSTGRES_HOST} + PG_META_DB_PORT: ${POSTGRES_PORT} + PG_META_DB_NAME: ${POSTGRES_DB} + PG_META_DB_USER: supabase_admin + PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD} + + functions: + container_name: supabase-edge-functions + image: supabase/edge-runtime:v1.58.3 + restart: unless-stopped + depends_on: + analytics: + condition: service_healthy + environment: + JWT_SECRET: ${JWT_SECRET} + SUPABASE_URL: http://kong:8000 + SUPABASE_ANON_KEY: ${ANON_KEY} + SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} + SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + # TODO: Allow configuring VERIFY_JWT per function. This PR might help: https://github.com/supabase/cli/pull/786 + VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}" + REDIS_URL: ${REDIS_URL} + volumes: + - ./volumes/functions:/home/deno/functions:Z + command: + - start + - --main-service + - /home/deno/functions/main + + analytics: + container_name: supabase-analytics + image: supabase/logflare:1.4.0 + healthcheck: + test: [ "CMD", "curl", "http://localhost:4000/health" ] + timeout: 5s + interval: 5s + retries: 10 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + # Uncomment to use Big Query backend for analytics + # volumes: + # - type: bind + # source: ${PWD}/gcloud.json + # target: /opt/app/rel/logflare/bin/gcloud.json + # read_only: true + environment: + LOGFLARE_NODE_HOST: 127.0.0.1 + DB_USERNAME: supabase_admin + DB_DATABASE: _supabase + DB_HOSTNAME: ${POSTGRES_HOST} + DB_PORT: ${POSTGRES_PORT} + DB_PASSWORD: ${POSTGRES_PASSWORD} + DB_SCHEMA: _analytics + LOGFLARE_API_KEY: ${LOGFLARE_API_KEY} + LOGFLARE_SINGLE_TENANT: true + LOGFLARE_SUPABASE_MODE: true + LOGFLARE_MIN_CLUSTER_SIZE: 1 + + # Comment variables to use Big Query backend for analytics + POSTGRES_BACKEND_URL: postgresql://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase + POSTGRES_BACKEND_SCHEMA: _analytics + LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true + # Uncomment to use Big Query backend for analytics + # GOOGLE_PROJECT_ID: ${GOOGLE_PROJECT_ID} + # GOOGLE_PROJECT_NUMBER: ${GOOGLE_PROJECT_NUMBER} + ports: + - 4000:4000 + + # Comment out everything below this point if you are using an external Postgres database + db: + container_name: supabase-db + image: supabase/postgres:15.1.1.78 + healthcheck: + test: pg_isready -U postgres -h localhost + interval: 5s + timeout: 5s + retries: 10 + depends_on: + vector: + condition: service_healthy + command: + - postgres + - -c + - config_file=/etc/postgresql/postgresql.conf + - -c + - log_min_messages=fatal # prevents Realtime polling queries from appearing in logs + restart: unless-stopped + environment: + POSTGRES_HOST: /var/run/postgresql + PGPORT: ${POSTGRES_PORT} + POSTGRES_PORT: ${POSTGRES_PORT} + PGPASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + PGDATABASE: ${POSTGRES_DB} + POSTGRES_DB: ${POSTGRES_DB} + JWT_SECRET: ${JWT_SECRET} + JWT_EXP: ${JWT_EXPIRY} + volumes: + - ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z + # Must be superuser to create event trigger + - ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z + # Must be superuser to alter reserved role + - ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z + # Initialize the database settings with JWT_SECRET and JWT_EXP + - ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z + # PGDATA directory is persisted between restarts + - ./volumes/db/data:/var/lib/postgresql/data:Z + # Changes required for internal supabase data such as _analytics + - ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:Z + # Changes required for Analytics support + - ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z + # Changes required for Pooler support + - ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z + # Volume to persist pgsodium decryption key between restarts + - ./volumes/db-config:/etc/postgresql-custom + + vector: + container_name: supabase-vector + image: timberio/vector:0.28.1-alpine + healthcheck: + test: + [ + + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://vector:9001/health" + ] + timeout: 5s + interval: 5s + retries: 3 + volumes: + - ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro + - ${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock:ro + environment: + LOGFLARE_API_KEY: ${LOGFLARE_API_KEY} + command: [ "--config", "etc/vector/vector.yml" ] + + # Update the DATABASE_URL if you are using an external Postgres database + supavisor: + container_name: supabase-pooler + image: supabase/supavisor:1.1.56 + healthcheck: + test: curl -sSfL --head -o /dev/null "http://127.0.0.1:4000/api/health" + interval: 10s + timeout: 5s + retries: 5 + depends_on: + db: + condition: service_healthy + analytics: + condition: service_healthy + command: + - /bin/sh + - -c + - /app/bin/migrate && /app/bin/supavisor eval "$$(cat /etc/pooler/pooler.exs)" && /app/bin/server + restart: unless-stopped + ports: + - ${POSTGRES_PORT}:${POSTGRES_PORT} + - ${POOLER_PROXY_PORT_TRANSACTION}:${POOLER_PROXY_PORT_TRANSACTION} + environment: + - PORT=4000 + - POSTGRES_PORT=${POSTGRES_PORT} + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - DATABASE_URL=ecto://postgres:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/_supabase + - CLUSTER_POSTGRES=true + - SECRET_KEY_BASE=UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq + - VAULT_ENC_KEY=your-encryption-key-32-chars-min + - API_JWT_SECRET=${JWT_SECRET} + - METRICS_JWT_SECRET=${JWT_SECRET} + - REGION=local + - ERL_AFLAGS=-proto_dist inet_tcp + - POOLER_TENANT_ID=your-tenant-id + - POOLER_DEFAULT_POOL_SIZE=${POOLER_DEFAULT_POOL_SIZE} + - POOLER_MAX_CLIENT_CONN=${POOLER_MAX_CLIENT_CONN} + - POOLER_POOL_MODE=transaction + volumes: + - ./volumes/pooler/pooler.exs:/etc/pooler/pooler.exs:ro diff --git a/supabase/migrations/0001_init.sql b/supabase/migrations/0001_init.sql new file mode 100644 index 0000000..442e9f2 --- /dev/null +++ b/supabase/migrations/0001_init.sql @@ -0,0 +1,128 @@ +-- v1 initial schema for linumiq tunnel platform +-- Phase 2 / Wave A backend + +BEGIN; + +CREATE EXTENSION IF NOT EXISTS citext; + +-- --------------------------------------------------------------------------- +-- Tables +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS public.tunnels ( + user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + subdomain citext UNIQUE NOT NULL + CHECK (subdomain ~ '^[a-z0-9-]{3,32}$' + AND subdomain NOT IN ('app','api','www','admin','auth','mail','static')), + token text UNIQUE NOT NULL, + is_active boolean NOT NULL DEFAULT true, + bytes_used bigint NOT NULL DEFAULT 0, + quota_bytes bigint NOT NULL DEFAULT 1099511627776, + created_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz +); + +CREATE TABLE IF NOT EXISTS public.subscriptions ( + user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + plan text NOT NULL DEFAULT 'free', + status text NOT NULL DEFAULT 'active', + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS public.usage_samples ( + id bigserial PRIMARY KEY, + subdomain citext NOT NULL, + ts timestamptz NOT NULL DEFAULT now(), + bytes_delta bigint NOT NULL +); +CREATE INDEX IF NOT EXISTS usage_samples_subdomain_ts_idx + ON public.usage_samples (subdomain, ts DESC); + +CREATE TABLE IF NOT EXISTS public.users_profile ( + user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + email text, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- --------------------------------------------------------------------------- +-- Trigger: auto-create users_profile row on auth.users insert +-- --------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + INSERT INTO public.users_profile (user_id, email) + VALUES (NEW.id, NEW.email) + ON CONFLICT (user_id) DO NOTHING; + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION public.handle_new_user() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.handle_new_user() TO supabase_auth_admin; + +DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users; +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); + +-- --------------------------------------------------------------------------- +-- Row-level security +-- --------------------------------------------------------------------------- + +ALTER TABLE public.tunnels ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.usage_samples ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.users_profile ENABLE ROW LEVEL SECURITY; + +-- tunnels: owner can SELECT and UPDATE. No INSERT/DELETE for users. +DROP POLICY IF EXISTS tunnels_select_own ON public.tunnels; +CREATE POLICY tunnels_select_own ON public.tunnels + FOR SELECT TO authenticated + USING (auth.uid() = user_id); + +DROP POLICY IF EXISTS tunnels_update_own ON public.tunnels; +CREATE POLICY tunnels_update_own ON public.tunnels + FOR UPDATE TO authenticated + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +-- subscriptions: owner can SELECT. +DROP POLICY IF EXISTS subscriptions_select_own ON public.subscriptions; +CREATE POLICY subscriptions_select_own ON public.subscriptions + FOR SELECT TO authenticated + USING (auth.uid() = user_id); + +-- usage_samples: no anon/authenticated access (service_role bypasses RLS). +-- (No policies created → all access denied for non-service roles.) + +-- users_profile: owner can SELECT and UPDATE. +DROP POLICY IF EXISTS users_profile_select_own ON public.users_profile; +CREATE POLICY users_profile_select_own ON public.users_profile + FOR SELECT TO authenticated + USING (auth.uid() = user_id); + +DROP POLICY IF EXISTS users_profile_update_own ON public.users_profile; +CREATE POLICY users_profile_update_own ON public.users_profile + FOR UPDATE TO authenticated + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +-- --------------------------------------------------------------------------- +-- Grants: let PostgREST roles see the tables; RLS still gates access. +-- --------------------------------------------------------------------------- + +GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role; + +GRANT SELECT, UPDATE ON public.tunnels TO authenticated; +GRANT SELECT ON public.subscriptions TO authenticated; +GRANT SELECT, UPDATE ON public.users_profile TO authenticated; + +GRANT ALL ON public.tunnels, public.subscriptions, public.usage_samples, public.users_profile TO service_role; +GRANT USAGE, SELECT ON SEQUENCE public.usage_samples_id_seq TO service_role; + +COMMIT;