Skip to content

Self-Service Signup + Stripe Integration Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the waitlist flow with real self-service signup (Supabase Auth), a user dashboard in the existing SPA, and Stripe Checkout/Portal for Pro tier payments.

Architecture: Supabase Auth provides email+password accounts with JWT sessions. The SPA talks to Supabase JS for auth and to the FastAPI backend for API key management. Stripe Checkout handles payment, and webhooks update the tier in api_keys. The dashboard lives in the same landing page SPA using hash-based routes.

Tech Stack: FastAPI, Supabase Auth (JS client via CDN), Stripe (Python stripe package, hosted Checkout + Customer Portal), React 18 via CDN (no build step)

Design doc: docs/plans/2026-05-19-self-service-signup-stripe-design.md

GitHub issues: #13 (signup), #14 (Stripe), #15 (E2E test)


File Structure

Modified files: - main.py — JWT validation helper, modified POST/GET /api-keys for JWT auth, new /checkout, /billing-portal, /stripe-webhook endpoints, CORS updates - requirements.txt — add stripe, PyJWT, cryptography - supabase_schema.sql — add user_id, stripe_customer_id, stripe_subscription_id columns to api_keys - landing/index.html — add Supabase JS CDN script tag - landing/components.jsx — nav auth state (Dashboard/Log out when logged in) - landing/signup.jsx — rewrite from waitlist to real signup form - landing/app.jsx — add routes for login, dashboard; auth state management - tests/conftest.py — add JWT auth fixtures

New files: - landing/login.jsx — login form component - landing/dashboard.jsx — dashboard component (API key card, usage card, plan card) - tests/test_jwt_auth.py — tests for JWT validation and JWT-authed endpoints - tests/test_stripe.py — tests for Stripe checkout, billing portal, and webhook endpoints


Task 1: Database Schema Migration

Add user_id, stripe_customer_id, and stripe_subscription_id columns to the api_keys table.

Files: - Modify: supabase_schema.sql

  • [ ] Step 1: Update schema file

Add these lines at the end of supabase_schema.sql:

-- Self-service signup: link API keys to Supabase Auth users
ALTER TABLE api_keys ADD COLUMN user_id UUID;
ALTER TABLE api_keys ADD COLUMN stripe_customer_id TEXT;
ALTER TABLE api_keys ADD COLUMN stripe_subscription_id TEXT;

CREATE INDEX idx_api_keys_user_id ON api_keys(user_id);
  • [ ] Step 2: Apply migration via Supabase MCP

Run the migration against the live database using the Supabase MCP apply_migration tool:

ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS user_id UUID;
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS stripe_customer_id TEXT;
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS stripe_subscription_id TEXT;
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
  • [ ] Step 3: Verify migration

Use the Supabase MCP list_tables tool with verbose: true to confirm the columns exist on api_keys.

  • [ ] Step 4: Commit
git add supabase_schema.sql
git commit -m "feat: add user_id and stripe columns to api_keys schema"

Task 2: Add Dependencies

Add stripe, PyJWT, and cryptography to requirements.

Files: - Modify: requirements.txt

  • [ ] Step 1: Add dependencies

Add these lines to requirements.txt (after slowapi, before mkdocs):

stripe
PyJWT
cryptography

The final requirements.txt should be:

fastapi
uvicorn[standard]
supabase
python-dotenv
httpx
sentry-sdk[fastapi]
segno
pytest
pytest-asyncio
pytest-cov
slowapi
stripe
PyJWT
cryptography
mkdocs
mkdocs-material
  • [ ] Step 2: Install locally

Run: pip install stripe PyJWT cryptography

  • [ ] Step 3: Commit
git add requirements.txt
git commit -m "feat: add stripe, PyJWT, cryptography dependencies"

Task 3: JWT Validation Helper + CORS Update

Add a validate_jwt() helper to main.py that validates Supabase Auth JWTs. Update CORS to allow GET, DELETE, and Authorization header.

Files: - Modify: main.py - Create: tests/test_jwt_auth.py

  • [ ] Step 1: Write failing tests for JWT validation

Create tests/test_jwt_auth.py:

"""Tests for JWT validation and JWT-authenticated endpoints."""
import hashlib
import time

import jwt
import pytest
from unittest.mock import Mock


# Shared test constants
SUPABASE_JWT_SECRET = "test-jwt-secret-that-is-long-enough-for-hs256-signing"
TEST_USER_ID = "aabbccdd-1122-3344-5566-778899001122"


@pytest.fixture(autouse=True)
def set_jwt_env(monkeypatch):
    """Set SUPABASE_JWT_SECRET for all tests in this module."""
    monkeypatch.setenv("SUPABASE_JWT_SECRET", SUPABASE_JWT_SECRET)


def make_jwt(user_id=TEST_USER_ID, exp_offset=3600, role="authenticated"):
    """Helper to create a valid Supabase-style JWT."""
    payload = {
        "sub": user_id,
        "role": role,
        "exp": int(time.time()) + exp_offset,
        "aud": "authenticated",
    }
    return jwt.encode(payload, SUPABASE_JWT_SECRET, algorithm="HS256")


def test_validate_jwt_valid_token(monkeypatch):
    """Test that a valid JWT returns the user ID."""
    import main
    monkeypatch.setattr(main, "SUPABASE_JWT_SECRET", SUPABASE_JWT_SECRET)

    from main import validate_jwt
    token = make_jwt()
    user_id = validate_jwt(token)
    assert user_id == TEST_USER_ID


def test_validate_jwt_expired_token(monkeypatch):
    """Test that an expired JWT raises 401."""
    import main
    from fastapi import HTTPException
    monkeypatch.setattr(main, "SUPABASE_JWT_SECRET", SUPABASE_JWT_SECRET)

    from main import validate_jwt
    token = make_jwt(exp_offset=-100)
    with pytest.raises(HTTPException) as exc_info:
        validate_jwt(token)
    assert exc_info.value.status_code == 401


def test_validate_jwt_invalid_signature(monkeypatch):
    """Test that a JWT signed with wrong secret raises 401."""
    import main
    from fastapi import HTTPException
    monkeypatch.setattr(main, "SUPABASE_JWT_SECRET", SUPABASE_JWT_SECRET)

    from main import validate_jwt
    token = jwt.encode(
        {"sub": TEST_USER_ID, "role": "authenticated", "exp": int(time.time()) + 3600, "aud": "authenticated"},
        "wrong-secret-key-that-is-long-enough",
        algorithm="HS256",
    )
    with pytest.raises(HTTPException) as exc_info:
        validate_jwt(token)
    assert exc_info.value.status_code == 401


def test_validate_jwt_missing_sub(monkeypatch):
    """Test that a JWT without sub claim raises 401."""
    import main
    from fastapi import HTTPException
    monkeypatch.setattr(main, "SUPABASE_JWT_SECRET", SUPABASE_JWT_SECRET)

    from main import validate_jwt
    token = jwt.encode(
        {"role": "authenticated", "exp": int(time.time()) + 3600, "aud": "authenticated"},
        SUPABASE_JWT_SECRET,
        algorithm="HS256",
    )
    with pytest.raises(HTTPException) as exc_info:
        validate_jwt(token)
    assert exc_info.value.status_code == 401
  • [ ] Step 2: Run tests to verify they fail

Run: pytest tests/test_jwt_auth.py -v Expected: FAIL — validate_jwt does not exist yet, SUPABASE_JWT_SECRET not defined in main.

  • [ ] Step 3: Implement JWT validation and CORS update

In main.py, add the import at the top (after import secrets):

import jwt as pyjwt

After the API_KEY check (around line 56), add:

SUPABASE_JWT_SECRET = os.getenv("SUPABASE_JWT_SECRET", "")

STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "")
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET", "")
STRIPE_PRICE_ID = os.getenv("STRIPE_PRICE_ID", "")

After the authenticate_request function (around line 446), add the validate_jwt function:

def validate_jwt(token: str) -> str:
    """
    Validate a Supabase Auth JWT and return the user ID.

    Args:
        token: Raw JWT string (without 'Bearer ' prefix).

    Returns:
        User ID string from the 'sub' claim.

    Raises:
        HTTPException 401 if the token is invalid, expired, or missing sub.
    """
    try:
        payload = pyjwt.decode(
            token,
            SUPABASE_JWT_SECRET,
            algorithms=["HS256"],
            audience="authenticated",
        )
    except pyjwt.ExpiredSignatureError:
        raise HTTPException(status_code=401, detail="Token expired")
    except pyjwt.InvalidTokenError:
        raise HTTPException(status_code=401, detail="Invalid token")

    user_id = payload.get("sub")
    if not user_id:
        raise HTTPException(status_code=401, detail="Invalid token: missing sub")
    return user_id

Update the CORS middleware (around line 146) to allow more methods and headers:

app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "https://yousnap.me",
        "http://localhost:3000",
        "http://localhost:8080",
    ],
    allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
    allow_headers=["Content-Type", "Authorization", "X-API-Key"],
)
  • [ ] Step 4: Run tests to verify they pass

Run: pytest tests/test_jwt_auth.py -v Expected: All 4 tests PASS.

  • [ ] Step 5: Run full test suite

Run: pytest -v Expected: All existing tests still pass + 4 new tests pass.

  • [ ] Step 6: Commit
git add main.py tests/test_jwt_auth.py
git commit -m "feat: add JWT validation helper and update CORS for dashboard"

Task 4: Modify POST /api-keys for JWT Auth

Allow POST /api-keys to accept Authorization: Bearer <jwt> in addition to master key. JWT-authenticated users get a free-tier key owned by their user_id.

Files: - Modify: main.py - Modify: tests/test_jwt_auth.py

  • [ ] Step 1: Write failing tests

Append to tests/test_jwt_auth.py:

class TestCreateApiKeyWithJWT:
    """Tests for POST /api-keys with JWT auth."""

    def test_create_key_with_jwt(self, test_client, mock_supabase, monkeypatch):
        """Test creating an API key with JWT auth creates a key owned by the user."""
        import main

        monkeypatch.setattr(main, "SUPABASE_JWT_SECRET", SUPABASE_JWT_SECRET)
        monkeypatch.setattr(
            main, "generate_api_key",
            lambda: ("usnap_k_jwtkey123456789012345678ab", "jwthash123", "jwtkey12"),
        )

        token = make_jwt()

        mock_table = Mock()
        mock_insert = Mock()
        mock_execute = Mock()
        mock_execute.data = [{
            "id": "770e8400-e29b-41d4-a716-446655440000",
            "key_hash": "jwthash123",
            "key_prefix": "jwtkey12",
            "label": "My dashboard key",
            "tier": "free",
            "webhook_secret": "a" * 64,
            "user_id": TEST_USER_ID,
            "created_at": "2026-05-19T10:00:00Z",
        }]
        mock_insert.execute.return_value = mock_execute
        mock_table.insert.return_value = mock_insert

        # Also mock select to check no existing key
        mock_select = Mock()
        mock_select_eq = Mock()
        mock_select_is = Mock()
        mock_select_execute = Mock()
        mock_select_execute.data = []
        mock_select.eq.return_value = mock_select_eq
        mock_select_eq.is_.return_value = mock_select_is
        mock_select_is.execute.return_value = mock_select_execute
        mock_table.select.return_value = mock_select

        def table_router(table_name):
            if table_name == "api_keys":
                return mock_table
            return mock_supabase.table(table_name)

        monkeypatch.setattr(main.supabase, "table", table_router)

        response = test_client.post(
            "/api-keys",
            json={"label": "My dashboard key"},
            headers={"Authorization": f"Bearer {token}"},
        )

        assert response.status_code == 200
        data = response.json()
        assert data["key"].startswith("usnap_k_")
        assert data["tier"] == "free"

    def test_create_key_with_jwt_no_x_api_key_header(self, test_client, mock_supabase, monkeypatch):
        """Test that JWT auth works without X-API-Key header."""
        import main

        monkeypatch.setattr(main, "SUPABASE_JWT_SECRET", SUPABASE_JWT_SECRET)
        monkeypatch.setattr(
            main, "generate_api_key",
            lambda: ("usnap_k_noheaderkey12345678901234ab", "nohash123", "noheade1"),
        )

        token = make_jwt()

        mock_table = Mock()
        mock_insert = Mock()
        mock_execute = Mock()
        mock_execute.data = [{
            "id": "880e8400-e29b-41d4-a716-446655440000",
            "key_hash": "nohash123",
            "key_prefix": "noheade1",
            "label": None,
            "tier": "free",
            "webhook_secret": "b" * 64,
            "user_id": TEST_USER_ID,
            "created_at": "2026-05-19T10:00:00Z",
        }]
        mock_insert.execute.return_value = mock_execute
        mock_table.insert.return_value = mock_insert

        mock_select = Mock()
        mock_select_eq = Mock()
        mock_select_is = Mock()
        mock_select_execute = Mock()
        mock_select_execute.data = []
        mock_select.eq.return_value = mock_select_eq
        mock_select_eq.is_.return_value = mock_select_is
        mock_select_is.execute.return_value = mock_select_execute
        mock_table.select.return_value = mock_select

        def table_router(table_name):
            if table_name == "api_keys":
                return mock_table
            return mock_supabase.table(table_name)

        monkeypatch.setattr(main.supabase, "table", table_router)

        response = test_client.post(
            "/api-keys",
            json={},
            headers={"Authorization": f"Bearer {token}"},
        )

        assert response.status_code == 200

    def test_create_key_rejects_no_auth(self, test_client, mock_supabase):
        """Test that POST /api-keys with no auth header returns 401."""
        response = test_client.post("/api-keys", json={})
        assert response.status_code == 401
  • [ ] Step 2: Run tests to verify they fail

Run: pytest tests/test_jwt_auth.py::TestCreateApiKeyWithJWT -v Expected: FAIL — current endpoint requires X-API-Key header.

  • [ ] Step 3: Modify POST /api-keys endpoint

Replace the create_api_key function in main.py (currently at line 850):

@app.post(
    "/api-keys",
    response_model=ApiKeyResponse,
    tags=["API Keys"],
    summary="Create a new API key",
    responses={
        401: {"description": "Invalid or missing authentication"},
        403: {"description": "Only the master key can create new keys (for non-JWT auth)"},
    },
)
def create_api_key(
    request: Request,
    body: ApiKeyCreate,
    x_api_key: Optional[str] = Header(None, description="Master API key"),
    authorization: Optional[str] = Header(None, description="Bearer JWT token"),
):
    """
    Create a new API key.

    Accepts either master key (X-API-Key) or JWT (Authorization: Bearer) auth.
    - Master key: creates a key with no user_id (admin provisioning).
    - JWT: creates a free-tier key owned by the authenticated user.

    The full key is returned once and never stored.
    """
    user_id = None

    if authorization and authorization.startswith("Bearer "):
        # JWT auth path — dashboard user creating their own key
        token = authorization[7:]
        user_id = validate_jwt(token)

        # Check if user already has an active key
        existing = (
            supabase.table("api_keys")
            .select("id")
            .eq("user_id", user_id)
            .is_("revoked_at", "null")
            .execute()
        )
        if existing.data:
            # Return existing key info (can't show raw key again)
            raise HTTPException(
                status_code=409,
                detail="You already have an active API key. Use rotate to get a new one.",
            )
    elif x_api_key:
        # Master key auth path
        api_key_id, _ = authenticate_request(x_api_key)
        if api_key_id is not None:
            raise HTTPException(status_code=403, detail="Only the master key can create new keys")
    else:
        raise HTTPException(status_code=401, detail="Authentication required")

    raw_key, key_hash, key_prefix = generate_api_key()
    webhook_secret = secrets.token_hex(32)

    row = {
        "key_hash": key_hash,
        "key_prefix": key_prefix,
        "label": body.label,
        "webhook_secret": webhook_secret,
    }
    if user_id:
        row["user_id"] = user_id

    result = supabase.table("api_keys").insert(row).execute()

    if not result.data:
        raise HTTPException(status_code=500, detail="Failed to create API key")

    record = result.data[0]

    return ApiKeyResponse(
        key=raw_key,
        id=record["id"],
        key_prefix=record["key_prefix"],
        label=record.get("label"),
        tier=record["tier"],
        webhook_secret=record["webhook_secret"],
        created_at=datetime.fromisoformat(record["created_at"].replace("Z", "+00:00")),
    )
  • [ ] Step 4: Run tests to verify they pass

Run: pytest tests/test_jwt_auth.py -v Expected: All tests PASS.

  • [ ] Step 5: Run full test suite to check for regressions

Run: pytest -v Expected: All tests pass. Existing TestCreateApiKey tests still work because they send X-API-Key header.

  • [ ] Step 6: Commit
git add main.py tests/test_jwt_auth.py
git commit -m "feat: allow JWT auth on POST /api-keys for self-service signup"

Task 5: Modify GET /api-keys for JWT Auth

Allow GET /api-keys to accept JWT auth. JWT users see only their own keys. Master key still sees all.

Files: - Modify: main.py - Modify: tests/test_jwt_auth.py

  • [ ] Step 1: Write failing tests

Append to tests/test_jwt_auth.py:

class TestListApiKeysWithJWT:
    """Tests for GET /api-keys with JWT auth."""

    def test_list_keys_with_jwt(self, test_client, mock_supabase, monkeypatch):
        """Test that JWT auth returns only the user's keys."""
        import main

        monkeypatch.setattr(main, "SUPABASE_JWT_SECRET", SUPABASE_JWT_SECRET)
        token = make_jwt()

        mock_table = Mock()
        mock_select = Mock()
        mock_eq = Mock()
        mock_execute = Mock()
        mock_execute.data = [{
            "id": "770e8400-e29b-41d4-a716-446655440000",
            "key_prefix": "jwtkey12",
            "label": "My key",
            "tier": "free",
            "created_at": "2026-05-19T10:00:00Z",
            "revoked_at": None,
        }]
        mock_eq.execute.return_value = mock_execute
        mock_select.eq.return_value = mock_eq
        mock_table.select.return_value = mock_select

        def table_router(table_name):
            if table_name == "api_keys":
                return mock_table
            return mock_supabase.table(table_name)

        monkeypatch.setattr(main.supabase, "table", table_router)

        response = test_client.get(
            "/api-keys",
            headers={"Authorization": f"Bearer {token}"},
        )

        assert response.status_code == 200
        data = response.json()
        assert len(data) == 1
        assert data[0]["key_prefix"] == "jwtkey12"

    def test_list_keys_no_auth_returns_401(self, test_client, mock_supabase):
        """Test that GET /api-keys with no auth returns 401."""
        response = test_client.get("/api-keys")
        assert response.status_code == 401
  • [ ] Step 2: Run tests to verify they fail

Run: pytest tests/test_jwt_auth.py::TestListApiKeysWithJWT -v Expected: FAIL.

  • [ ] Step 3: Modify GET /api-keys endpoint

Replace the list_api_keys function in main.py:

@app.get(
    "/api-keys",
    response_model=List[ApiKeyListItem],
    tags=["API Keys"],
    summary="List API keys",
    responses={
        401: {"description": "Invalid or missing authentication"},
    },
)
def list_api_keys(
    x_api_key: Optional[str] = Header(None, description="Master API key"),
    authorization: Optional[str] = Header(None, description="Bearer JWT token"),
):
    """
    List API keys. JWT users see only their own keys. Master key sees all.
    """
    if authorization and authorization.startswith("Bearer "):
        token = authorization[7:]
        user_id = validate_jwt(token)

        result = (
            supabase.table("api_keys")
            .select("id, key_prefix, label, tier, created_at, revoked_at")
            .eq("user_id", user_id)
            .execute()
        )
    elif x_api_key:
        api_key_id, _ = authenticate_request(x_api_key)
        if api_key_id is not None:
            raise HTTPException(status_code=403, detail="Only the master key can list keys")

        result = (
            supabase.table("api_keys")
            .select("id, key_prefix, label, tier, created_at, revoked_at")
            .execute()
        )
    else:
        raise HTTPException(status_code=401, detail="Authentication required")

    return [
        ApiKeyListItem(
            id=row["id"],
            key_prefix=row["key_prefix"],
            label=row.get("label"),
            tier=row["tier"],
            created_at=datetime.fromisoformat(row["created_at"].replace("Z", "+00:00")),
            revoked_at=(
                datetime.fromisoformat(row["revoked_at"].replace("Z", "+00:00"))
                if row.get("revoked_at")
                else None
            ),
        )
        for row in result.data
    ]
  • [ ] Step 4: Run tests to verify they pass

Run: pytest tests/test_jwt_auth.py -v Expected: All tests PASS.

  • [ ] Step 5: Run full suite

Run: pytest -v Expected: All pass.

  • [ ] Step 6: Commit
git add main.py tests/test_jwt_auth.py
git commit -m "feat: allow JWT auth on GET /api-keys for dashboard"

Task 6: Stripe Endpoints (Checkout, Billing Portal, Webhook)

Add POST /checkout, POST /billing-portal, and POST /stripe-webhook endpoints.

Files: - Modify: main.py - Create: tests/test_stripe.py

  • [ ] Step 1: Write failing tests

Create tests/test_stripe.py:

"""Tests for Stripe integration endpoints."""
import hashlib
import hmac
import json
import time

import jwt
import pytest
from unittest.mock import Mock, patch, MagicMock


SUPABASE_JWT_SECRET = "test-jwt-secret-that-is-long-enough-for-hs256-signing"
TEST_USER_ID = "aabbccdd-1122-3344-5566-778899001122"
TEST_KEY_ID = "770e8400-e29b-41d4-a716-446655440000"


@pytest.fixture(autouse=True)
def set_stripe_env(monkeypatch):
    """Set Stripe env vars for all tests in this module."""
    monkeypatch.setenv("SUPABASE_JWT_SECRET", SUPABASE_JWT_SECRET)
    monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_test_fake123")
    monkeypatch.setenv("STRIPE_WEBHOOK_SECRET", "whsec_test_fake456")
    monkeypatch.setenv("STRIPE_PRICE_ID", "price_test_fake789")


def make_jwt_token(user_id=TEST_USER_ID):
    """Create a valid JWT for testing."""
    payload = {
        "sub": user_id,
        "role": "authenticated",
        "exp": int(time.time()) + 3600,
        "aud": "authenticated",
    }
    return jwt.encode(payload, SUPABASE_JWT_SECRET, algorithm="HS256")


class TestCheckout:
    """Tests for POST /checkout."""

    @patch("stripe.checkout.Session.create")
    def test_checkout_creates_session(self, mock_stripe_create, test_client, mock_supabase, monkeypatch):
        """Test that POST /checkout creates a Stripe Checkout session."""
        import main
        monkeypatch.setattr(main, "SUPABASE_JWT_SECRET", SUPABASE_JWT_SECRET)
        monkeypatch.setattr(main, "STRIPE_SECRET_KEY", "sk_test_fake123")
        monkeypatch.setattr(main, "STRIPE_PRICE_ID", "price_test_fake789")

        token = make_jwt_token()

        # Mock Supabase to return user's API key
        mock_table = Mock()
        mock_select = Mock()
        mock_eq = Mock()
        mock_is = Mock()
        mock_execute = Mock()
        mock_execute.data = [{
            "id": TEST_KEY_ID,
            "tier": "free",
            "stripe_customer_id": None,
        }]
        mock_select.eq.return_value = mock_eq
        mock_eq.is_.return_value = mock_is
        mock_is.execute.return_value = mock_execute
        mock_table.select.return_value = mock_select

        def table_router(table_name):
            if table_name == "api_keys":
                return mock_table
            return mock_supabase.table(table_name)

        monkeypatch.setattr(main.supabase, "table", table_router)

        mock_stripe_create.return_value = MagicMock(url="https://checkout.stripe.com/pay/test123")

        response = test_client.post(
            "/checkout",
            headers={"Authorization": f"Bearer {token}"},
        )

        assert response.status_code == 200
        data = response.json()
        assert "url" in data
        assert data["url"] == "https://checkout.stripe.com/pay/test123"

    def test_checkout_requires_auth(self, test_client, mock_supabase):
        """Test that POST /checkout without auth returns 401."""
        response = test_client.post("/checkout")
        assert response.status_code == 401


class TestBillingPortal:
    """Tests for POST /billing-portal."""

    @patch("stripe.billing_portal.Session.create")
    def test_billing_portal_creates_session(self, mock_portal_create, test_client, mock_supabase, monkeypatch):
        """Test that POST /billing-portal creates a Stripe Portal session."""
        import main
        monkeypatch.setattr(main, "SUPABASE_JWT_SECRET", SUPABASE_JWT_SECRET)
        monkeypatch.setattr(main, "STRIPE_SECRET_KEY", "sk_test_fake123")

        token = make_jwt_token()

        mock_table = Mock()
        mock_select = Mock()
        mock_eq = Mock()
        mock_is = Mock()
        mock_execute = Mock()
        mock_execute.data = [{
            "id": TEST_KEY_ID,
            "tier": "pro",
            "stripe_customer_id": "cus_test123",
        }]
        mock_select.eq.return_value = mock_eq
        mock_eq.is_.return_value = mock_is
        mock_is.execute.return_value = mock_execute
        mock_table.select.return_value = mock_select

        def table_router(table_name):
            if table_name == "api_keys":
                return mock_table
            return mock_supabase.table(table_name)

        monkeypatch.setattr(main.supabase, "table", table_router)

        mock_portal_create.return_value = MagicMock(url="https://billing.stripe.com/session/test")

        response = test_client.post(
            "/billing-portal",
            headers={"Authorization": f"Bearer {token}"},
        )

        assert response.status_code == 200
        data = response.json()
        assert "url" in data

    def test_billing_portal_requires_stripe_customer(self, test_client, mock_supabase, monkeypatch):
        """Test that billing portal fails if user has no Stripe customer ID."""
        import main
        monkeypatch.setattr(main, "SUPABASE_JWT_SECRET", SUPABASE_JWT_SECRET)

        token = make_jwt_token()

        mock_table = Mock()
        mock_select = Mock()
        mock_eq = Mock()
        mock_is = Mock()
        mock_execute = Mock()
        mock_execute.data = [{
            "id": TEST_KEY_ID,
            "tier": "free",
            "stripe_customer_id": None,
        }]
        mock_select.eq.return_value = mock_eq
        mock_eq.is_.return_value = mock_is
        mock_is.execute.return_value = mock_execute
        mock_table.select.return_value = mock_select

        def table_router(table_name):
            if table_name == "api_keys":
                return mock_table
            return mock_supabase.table(table_name)

        monkeypatch.setattr(main.supabase, "table", table_router)

        response = test_client.post(
            "/billing-portal",
            headers={"Authorization": f"Bearer {token}"},
        )

        assert response.status_code == 400


class TestStripeWebhook:
    """Tests for POST /stripe-webhook."""

    def test_webhook_checkout_completed(self, test_client, mock_supabase, monkeypatch):
        """Test that checkout.session.completed webhook upgrades tier to pro."""
        import main
        monkeypatch.setattr(main, "STRIPE_WEBHOOK_SECRET", "whsec_test_fake456")

        event_data = {
            "type": "checkout.session.completed",
            "data": {
                "object": {
                    "customer": "cus_test_abc",
                    "subscription": "sub_test_xyz",
                    "metadata": {"api_key_id": TEST_KEY_ID},
                }
            },
        }
        payload = json.dumps(event_data)

        # Mock stripe.Webhook.construct_event to return our event
        with patch("stripe.Webhook.construct_event", return_value=event_data):
            mock_table = Mock()
            mock_update = Mock()
            mock_eq = Mock()
            mock_execute = Mock()
            mock_execute.data = [{"id": TEST_KEY_ID}]
            mock_eq.execute.return_value = mock_execute
            mock_update.eq.return_value = mock_eq
            mock_table.update.return_value = mock_update

            def table_router(table_name):
                if table_name == "api_keys":
                    return mock_table
                return mock_supabase.table(table_name)

            monkeypatch.setattr(main.supabase, "table", table_router)

            response = test_client.post(
                "/stripe-webhook",
                content=payload,
                headers={
                    "Content-Type": "application/json",
                    "Stripe-Signature": "fake_sig",
                },
            )

        assert response.status_code == 200

        # Verify the update was called with pro tier and stripe IDs
        update_args = mock_table.update.call_args[0][0]
        assert update_args["tier"] == "pro"
        assert update_args["stripe_customer_id"] == "cus_test_abc"
        assert update_args["stripe_subscription_id"] == "sub_test_xyz"

    def test_webhook_subscription_deleted(self, test_client, mock_supabase, monkeypatch):
        """Test that customer.subscription.deleted webhook downgrades to free."""
        import main
        monkeypatch.setattr(main, "STRIPE_WEBHOOK_SECRET", "whsec_test_fake456")

        event_data = {
            "type": "customer.subscription.deleted",
            "data": {
                "object": {
                    "id": "sub_test_xyz",
                    "customer": "cus_test_abc",
                }
            },
        }
        payload = json.dumps(event_data)

        with patch("stripe.Webhook.construct_event", return_value=event_data):
            mock_table = Mock()
            mock_update = Mock()
            mock_eq = Mock()
            mock_execute = Mock()
            mock_execute.data = [{"id": TEST_KEY_ID}]
            mock_eq.execute.return_value = mock_execute
            mock_update.eq.return_value = mock_eq
            mock_table.update.return_value = mock_update

            def table_router(table_name):
                if table_name == "api_keys":
                    return mock_table
                return mock_supabase.table(table_name)

            monkeypatch.setattr(main.supabase, "table", table_router)

            response = test_client.post(
                "/stripe-webhook",
                content=payload,
                headers={
                    "Content-Type": "application/json",
                    "Stripe-Signature": "fake_sig",
                },
            )

        assert response.status_code == 200
        update_args = mock_table.update.call_args[0][0]
        assert update_args["tier"] == "free"
  • [ ] Step 2: Run tests to verify they fail

Run: pytest tests/test_stripe.py -v Expected: FAIL — endpoints don't exist yet.

  • [ ] Step 3: Implement Stripe endpoints

Add import stripe at the top of main.py (after import jwt as pyjwt):

import stripe

After the env var block where STRIPE_SECRET_KEY etc. are loaded, add:

if STRIPE_SECRET_KEY:
    stripe.api_key = STRIPE_SECRET_KEY

Add these three endpoints before the /{short_id}/webhooks/status endpoint (around line 1097):

@app.post(
    "/checkout",
    tags=["Billing"],
    summary="Create a Stripe Checkout session",
    responses={
        401: {"description": "Authentication required"},
        400: {"description": "Already on Pro tier"},
    },
)
def create_checkout_session(
    request: Request,
    authorization: Optional[str] = Header(None, description="Bearer JWT token"),
):
    """
    Create a Stripe Checkout session for upgrading to Pro.
    Requires JWT auth. Returns the Stripe Checkout URL.
    """
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Authentication required")

    token = authorization[7:]
    user_id = validate_jwt(token)

    # Get user's active API key
    key_result = (
        supabase.table("api_keys")
        .select("id, tier, stripe_customer_id")
        .eq("user_id", user_id)
        .is_("revoked_at", "null")
        .execute()
    )

    if not key_result.data:
        raise HTTPException(status_code=404, detail="No active API key found")

    key = key_result.data[0]

    if key["tier"] == "pro":
        raise HTTPException(status_code=400, detail="Already on Pro tier")

    checkout_params = {
        "mode": "subscription",
        "line_items": [{"price": STRIPE_PRICE_ID, "quantity": 1}],
        "success_url": "https://yousnap.me/#dashboard?upgraded=true",
        "cancel_url": "https://yousnap.me/#dashboard",
        "metadata": {"api_key_id": key["id"]},
    }

    # Reuse existing Stripe customer if available
    if key.get("stripe_customer_id"):
        checkout_params["customer"] = key["stripe_customer_id"]

    session = stripe.checkout.Session.create(**checkout_params)

    return {"url": session.url}


@app.post(
    "/billing-portal",
    tags=["Billing"],
    summary="Create a Stripe Billing Portal session",
    responses={
        401: {"description": "Authentication required"},
        400: {"description": "No Stripe customer found"},
    },
)
def create_billing_portal_session(
    request: Request,
    authorization: Optional[str] = Header(None, description="Bearer JWT token"),
):
    """
    Create a Stripe Customer Portal session for managing billing.
    Requires JWT auth and an existing Stripe customer ID.
    """
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Authentication required")

    token = authorization[7:]
    user_id = validate_jwt(token)

    key_result = (
        supabase.table("api_keys")
        .select("id, stripe_customer_id")
        .eq("user_id", user_id)
        .is_("revoked_at", "null")
        .execute()
    )

    if not key_result.data:
        raise HTTPException(status_code=404, detail="No active API key found")

    customer_id = key_result.data[0].get("stripe_customer_id")
    if not customer_id:
        raise HTTPException(status_code=400, detail="No billing account found. Upgrade to Pro first.")

    session = stripe.billing_portal.Session.create(
        customer=customer_id,
        return_url="https://yousnap.me/#dashboard",
    )

    return {"url": session.url}


@app.post(
    "/stripe-webhook",
    tags=["Billing"],
    summary="Handle Stripe webhook events",
    include_in_schema=False,
)
async def stripe_webhook(request: Request):
    """
    Handle Stripe webhook events. Verifies signature, no JWT needed.

    Handles:
    - checkout.session.completed → upgrade tier to pro, store stripe IDs
    - customer.subscription.deleted → downgrade tier to free
    """
    payload = await request.body()
    sig_header = request.headers.get("Stripe-Signature", "")

    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, STRIPE_WEBHOOK_SECRET
        )
    except (ValueError, stripe.error.SignatureVerificationError):
        raise HTTPException(status_code=400, detail="Invalid webhook signature")

    event_type = event["type"]

    if event_type == "checkout.session.completed":
        session = event["data"]["object"]
        api_key_id = session.get("metadata", {}).get("api_key_id")
        customer_id = session.get("customer")
        subscription_id = session.get("subscription")

        if api_key_id:
            supabase.table("api_keys").update({
                "tier": "pro",
                "stripe_customer_id": customer_id,
                "stripe_subscription_id": subscription_id,
            }).eq("id", api_key_id).execute()

    elif event_type == "customer.subscription.deleted":
        subscription = event["data"]["object"]
        subscription_id = subscription.get("id")

        if subscription_id:
            supabase.table("api_keys").update({
                "tier": "free",
                "stripe_subscription_id": None,
            }).eq("stripe_subscription_id", subscription_id).execute()

    return {"status": "ok"}

Also add "Billing" to the openapi_tags list in the FastAPI() constructor:

{"name": "Billing", "description": "Stripe checkout and billing management."},
  • [ ] Step 4: Run tests to verify they pass

Run: pytest tests/test_stripe.py -v Expected: All 5 tests PASS.

  • [ ] Step 5: Run full suite

Run: pytest -v Expected: All tests pass.

  • [ ] Step 6: Commit
git add main.py tests/test_stripe.py
git commit -m "feat: add Stripe checkout, billing portal, and webhook endpoints"

Task 7: Add Supabase JS CDN to index.html

Add the Supabase JavaScript client library via CDN to the landing page.

Files: - Modify: landing/index.html

  • [ ] Step 1: Add Supabase JS script tag

Add this script tag after the Babel script tag (line 31) and before the component scripts:

  <script src="https://unpkg.com/@supabase/supabase-js@2/dist/umd/supabase.min.js"></script>

The script section should look like:

  <script src="https://unpkg.com/[email protected]/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
  <script src="https://unpkg.com/[email protected]/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
  <script src="https://unpkg.com/@babel/[email protected]/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
  <script src="https://unpkg.com/@supabase/supabase-js@2/dist/umd/supabase.min.js"></script>

  <script type="text/babel" src="components.jsx"></script>
  • [ ] Step 2: Commit
git add landing/index.html
git commit -m "feat: add Supabase JS client CDN to landing page"

Task 8: Login Component

Create the login form component.

Files: - Create: landing/login.jsx - Modify: landing/index.html (add script tag)

  • [ ] Step 1: Create login.jsx

Create landing/login.jsx:

/* Login form */

function Login({ go, supabase }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [loading, setLoading] = useState(false);

  async function handleSubmit(e) {
    e.preventDefault();
    setError('');
    setLoading(true);

    const { data, error: authError } = await supabase.auth.signInWithPassword({
      email,
      password,
    });

    if (authError) {
      setError(authError.message);
      setLoading(false);
      return;
    }

    go('dashboard');
  }

  return (
    <div className="section" style={{padding: '60px 0', position: 'relative'}}>
      <Spot size={300} color="var(--c-pastel-yellow)" top={-120} right={-100} opacity={0.7} />
      <Spot size={180} color="var(--c-pastel-blue)" bottom={-60} left={-40} opacity={0.6} />

      <div className="container container--narrow">
        <div className="center" style={{marginBottom: 24}}>
          <Wonky color="var(--c-blue)" fg="#fff">Welcome back</Wonky>
        </div>
        <h1 className="hero__title" style={{fontSize: 'clamp(36px, 5vw, 64px)', textAlign: 'center', margin: '0 auto 16px'}}>
          Log in to your dashboard.
        </h1>

        <div style={{
          background: 'var(--bg)',
          border: '2px solid var(--fg)',
          borderRadius: 20,
          padding: 36,
          maxWidth: 420,
          margin: '0 auto',
          position: 'relative'
        }}>
          <Sparkle size={20} color="#FCC31E" style={{position: 'absolute', top: -10, right: 20}}/>

          <form className="form" onSubmit={handleSubmit}>
            <div className="field">
              <label htmlFor="login-email">Email</label>
              <input
                id="login-email"
                type="email"
                placeholder="[email protected]"
                value={email}
                onChange={e => setEmail(e.target.value)}
                required
              />
            </div>

            <div className="field">
              <label htmlFor="login-password">Password</label>
              <input
                id="login-password"
                type="password"
                placeholder="••••••••"
                value={password}
                onChange={e => setPassword(e.target.value)}
                required
              />
            </div>

            {error && <div className="field__error">{error}</div>}

            <button className="btn btn--primary btn--lg" type="submit" disabled={loading} style={{width: '100%'}}>
              {loading ? 'Logging in…' : 'Log in'}
            </button>
          </form>

          <div style={{marginTop: 16, textAlign: 'center'}}>
            <span className="muted" style={{fontSize: 14}}>Don't have an account? </span>
            <a style={{fontSize: 14, fontWeight: 600, cursor: 'pointer', color: 'var(--c-pink)'}} onClick={() => go('signup')}>Sign up</a>
          </div>
        </div>
      </div>
    </div>
  );
}

Object.assign(window, { Login });
  • [ ] Step 2: Add script tag to index.html

In landing/index.html, add the login script after signup.jsx:

  <script type="text/babel" src="login.jsx"></script>

The script section should be:

  <script type="text/babel" src="components.jsx"></script>
  <script type="text/babel" src="landing.jsx"></script>
  <script type="text/babel" src="signup.jsx"></script>
  <script type="text/babel" src="login.jsx"></script>
  <script type="text/babel" src="pricing.jsx"></script>
  <script type="text/babel" src="app.jsx"></script>
  • [ ] Step 3: Commit
git add landing/login.jsx landing/index.html
git commit -m "feat: add login form component"

Task 9: Rewrite Signup Component

Rewrite signup.jsx from waitlist to real signup using Supabase Auth. After signup, auto-create an API key and show it once.

Files: - Modify: landing/signup.jsx

  • [ ] Step 1: Rewrite signup.jsx

Replace the entire contents of landing/signup.jsx:

/* Sign-up: real account creation via Supabase Auth */

function SignUp({ go, supabase, onAuth }) {
  const [state, setState] = useState('form'); // form, submitting, created
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [apiKey, setApiKey] = useState(null);
  const [webhookSecret, setWebhookSecret] = useState(null);
  const [copied, setCopied] = useState(false);

  async function handleSubmit(e) {
    e.preventDefault();
    if (!/^\S+@\S+\.\S+$/.test(email)) { setError("That doesn't look like an email address."); return; }
    if (password.length < 6) { setError("Password must be at least 6 characters."); return; }
    setError('');
    setState('submitting');

    // 1. Sign up with Supabase Auth
    const { data: authData, error: authError } = await supabase.auth.signUp({
      email,
      password,
    });

    if (authError) {
      setError(authError.message);
      setState('form');
      return;
    }

    // 2. Get the session token
    const session = authData.session;
    if (!session) {
      // Email confirmation required — show message
      setState('confirm-email');
      return;
    }

    // 3. Create API key via backend
    try {
      const res = await fetch("https://usnp.me/api-keys", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Authorization": `Bearer ${session.access_token}`,
        },
        body: JSON.stringify({ label: "Dashboard key" }),
      });

      if (!res.ok) {
        const err = await res.json();
        throw new Error(err.detail || "Failed to create API key");
      }

      const keyData = await res.json();
      setApiKey(keyData.key);
      setWebhookSecret(keyData.webhook_secret);
      setState('created');
      if (onAuth) onAuth(session);
    } catch (err) {
      setError(err.message);
      setState('form');
    }
  }

  function copyKey() {
    navigator.clipboard?.writeText(apiKey).catch(() => {});
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  }

  return (
    <div className="section" style={{padding: '60px 0', position: 'relative'}}>
      <Spot size={300} color="var(--c-pastel-yellow)" top={-120} right={-100} opacity={0.7} />
      <Spot size={180} color="var(--c-pastel-blue)" bottom={-60} left={-40} opacity={0.6} />

      <div className="container container--narrow">
        <div className="center" style={{marginBottom: 24}}>
          <Wonky color="var(--c-pink)" fg="#fff">Hello, builder.</Wonky>
        </div>
        <h1 className="hero__title" style={{fontSize: 'clamp(36px, 5vw, 64px)', textAlign: 'center', margin: '0 auto 16px'}}>
          {state === 'created' ? 'Your API key is ready.' : 'Create your account.'}
        </h1>

        <div style={{
          background: 'var(--bg)',
          border: '2px solid var(--fg)',
          borderRadius: 20,
          padding: 36,
          maxWidth: 520,
          margin: '0 auto',
          position: 'relative'
        }}>
          <Sparkle size={20} color="#FCC31E" style={{position: 'absolute', top: -10, right: 20}}/>

          {state === 'form' || state === 'submitting' ? (
            <>
              <h2 style={{margin: '0 0 6px', fontSize: 24, letterSpacing: '-0.02em'}}>Sign up</h2>
              <p className="muted" style={{margin: '0 0 24px', fontSize: 14}}>Free tier. No credit card. API key in 10 seconds.</p>

              <form className="form" onSubmit={handleSubmit}>
                <div className="field">
                  <label htmlFor="signup-email">Email</label>
                  <input
                    id="signup-email"
                    type="email"
                    placeholder="[email protected]"
                    value={email}
                    onChange={e => setEmail(e.target.value)}
                    required
                  />
                </div>

                <div className="field">
                  <label htmlFor="signup-password">Password</label>
                  <input
                    id="signup-password"
                    type="password"
                    placeholder="At least 6 characters"
                    value={password}
                    onChange={e => setPassword(e.target.value)}
                    required
                    minLength={6}
                  />
                </div>

                {error && <div className="field__error">{error}</div>}

                <button className="btn btn--primary btn--lg" type="submit" disabled={state === 'submitting'} style={{width: '100%'}}>
                  {state === 'submitting' ? 'Creating your account…' : 'Create account & get API key →'}
                </button>

                <p className="muted" style={{fontSize: 12, margin: 0}}>By signing up you agree to play nice. We'll never share your email.</p>
              </form>

              <div style={{marginTop: 16, textAlign: 'center'}}>
                <span className="muted" style={{fontSize: 14}}>Already have an account? </span>
                <a style={{fontSize: 14, fontWeight: 600, cursor: 'pointer', color: 'var(--c-pink)'}} onClick={() => go('login')}>Log in</a>
              </div>
            </>
          ) : state === 'confirm-email' ? (
            <div className="center" style={{padding: '20px 0'}}>
              <div style={{
                width: 80, height: 80, borderRadius: '50%',
                background: 'var(--c-pastel-blue)', margin: '0 auto 20px',
                display: 'grid', placeItems: 'center',
              }}>
                <svg width="36" height="36" viewBox="0 0 36 36">
                  <path d="M6 12 L18 22 L30 12" stroke="#1a5276" strokeWidth="3" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
                  <rect x="4" y="10" width="28" height="18" rx="3" stroke="#1a5276" strokeWidth="2.5" fill="none"/>
                </svg>
              </div>
              <h2 style={{margin: '0 0 8px'}}>Check your email.</h2>
              <p className="muted" style={{margin: '0 auto 24px', maxWidth: '40ch', lineHeight: 1.5}}>
                We sent a confirmation link to <b style={{color: 'var(--fg)'}}>{email}</b>. Click it to activate your account, then come back and log in.
              </p>
              <button className="btn btn--primary" onClick={() => go('login')}>Go to login →</button>
            </div>
          ) : (
            <div className="center" style={{padding: '20px 0'}}>
              <div style={{
                width: 80, height: 80, borderRadius: '50%',
                background: 'var(--c-pastel-green)', margin: '0 auto 20px',
                display: 'grid', placeItems: 'center',
                transform: 'rotate(-3deg)'
              }}>
                <svg width="36" height="36" viewBox="0 0 36 36">
                  <path d="M8 18 L15 25 L28 11" stroke="#2a5b00" strokeWidth="4" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
                </svg>
              </div>
              <h2 style={{margin: '0 0 12px'}}>Copy your API key now.</h2>
              <p className="muted" style={{margin: '0 auto 16px', maxWidth: '44ch', lineHeight: 1.5}}>
                This is the only time we'll show the full key. Store it somewhere safe.
              </p>

              <div style={{
                background: '#1a1920',
                borderRadius: 12,
                padding: '16px 20px',
                fontFamily: 'var(--font-mono)',
                fontSize: 13,
                color: '#e8e6e3',
                textAlign: 'left',
                wordBreak: 'break-all',
                position: 'relative',
                marginBottom: 16,
              }}>
                <div style={{marginBottom: 8}}>
                  <span style={{color: '#888', fontSize: 11}}>API Key</span><br/>
                  {apiKey}
                </div>
                <div>
                  <span style={{color: '#888', fontSize: 11}}>Webhook Secret</span><br/>
                  <span style={{fontSize: 11}}>{webhookSecret}</span>
                </div>
                <button
                  onClick={copyKey}
                  style={{
                    position: 'absolute', top: 12, right: 12,
                    background: 'rgba(255,255,255,.1)', border: 'none',
                    borderRadius: 6, padding: '4px 10px',
                    color: '#aaa', fontSize: 11, fontFamily: 'var(--font-mono)',
                    cursor: 'pointer',
                  }}
                >{copied ? '✓ copied' : 'copy key'}</button>
              </div>

              <div style={{display: 'flex', gap: 12, justifyContent: 'center'}}>
                <button className="btn btn--primary" onClick={() => go('dashboard')}>Go to dashboard </button>
                <a className="btn btn--ghost" href="/docs/quickstart/">Quickstart docs</a>
              </div>
            </div>
          )}
        </div>

        <div className="spacer-lg"/>
        <div style={{
          display: 'flex',
          justifyContent: 'center',
          gap: 36,
          fontSize: 13,
          color: 'var(--muted)',
          flexWrap: 'wrap',
        }}>
          <div className="row"><span style={{color: 'var(--c-green)'}}></span> SOC-2 in progress</div>
          <div className="row"><span style={{color: 'var(--c-green)'}}></span> GDPR compliant</div>
          <div className="row"><span style={{color: 'var(--c-green)'}}></span> No vendor lock-in · cancel anytime</div>
        </div>
      </div>
    </div>
  );
}

Object.assign(window, { SignUp });
  • [ ] Step 2: Commit
git add landing/signup.jsx
git commit -m "feat: rewrite signup from waitlist to real Supabase Auth account creation"

Task 10: Dashboard Component

Create the dashboard component with API key card, usage card, and plan card.

Files: - Create: landing/dashboard.jsx - Modify: landing/index.html (add script tag)

  • [ ] Step 1: Create dashboard.jsx

Create landing/dashboard.jsx:

/* Dashboard: API key, usage, and billing */

function Dashboard({ go, supabase, session }) {
  const [keys, setKeys] = useState([]);
  const [usage, setUsage] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState('');

  const token = session?.access_token;

  useEffect(() => {
    if (!token) return;
    loadData();
  }, [token]);

  async function loadData() {
    setLoading(true);
    try {
      const keysRes = await fetch("https://usnp.me/api-keys", {
        headers: { "Authorization": `Bearer ${token}` },
      });
      if (keysRes.ok) {
        setKeys(await keysRes.json());
      }
    } catch (err) {
      setError("Failed to load dashboard data.");
    }
    setLoading(false);
  }

  async function handleUpgrade() {
    try {
      const res = await fetch("https://usnp.me/checkout", {
        method: "POST",
        headers: { "Authorization": `Bearer ${token}` },
      });
      if (res.ok) {
        const { url } = await res.json();
        window.location.href = url;
      } else {
        const err = await res.json();
        setError(err.detail || "Failed to start checkout");
      }
    } catch (err) {
      setError("Failed to start checkout");
    }
  }

  async function handleManageBilling() {
    try {
      const res = await fetch("https://usnp.me/billing-portal", {
        method: "POST",
        headers: { "Authorization": `Bearer ${token}` },
      });
      if (res.ok) {
        const { url } = await res.json();
        window.location.href = url;
      } else {
        const err = await res.json();
        setError(err.detail || "Failed to open billing portal");
      }
    } catch (err) {
      setError("Failed to open billing portal");
    }
  }

  async function handleLogout() {
    await supabase.auth.signOut();
    go('landing');
  }

  if (!session) {
    go('login');
    return null;
  }

  const activeKey = keys.find(k => !k.revoked_at);
  const tier = activeKey?.tier || 'free';

  return (
    <div className="section" style={{padding: '60px 0', position: 'relative', minHeight: '60vh'}}>
      <Spot size={300} color="var(--c-pastel-yellow)" top={-120} right={-100} opacity={0.7} />

      <div className="container container--narrow">
        <div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 32}}>
          <h1 style={{fontSize: 'clamp(28px, 4vw, 40px)', margin: 0, letterSpacing: '-0.02em'}}>Dashboard</h1>
          <button className="btn btn--ghost btn--sm" onClick={handleLogout}>Log out</button>
        </div>

        {error && (
          <div style={{
            background: '#fff0f0', border: '1px solid #ffcccc', borderRadius: 12,
            padding: '12px 16px', marginBottom: 24, fontSize: 14, color: '#cc0000'
          }}>
            {error}
          </div>
        )}

        {loading ? (
          <div className="center muted" style={{padding: 60}}>Loading</div>
        ) : (
          <div style={{display: 'flex', flexDirection: 'column', gap: 24}}>

            {/* API Key Card */}
            <div style={{
              background: 'var(--bg)', border: '2px solid var(--fg)',
              borderRadius: 16, padding: 28
            }}>
              <div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16}}>
                <h2 style={{margin: 0, fontSize: 20}}>API Key</h2>
                <span style={{
                  background: tier === 'pro' ? 'var(--c-yellow)' : 'var(--c-pastel-blue)',
                  color: 'var(--c-black)',
                  padding: '4px 12px', borderRadius: 20, fontSize: 12, fontWeight: 700,
                  textTransform: 'uppercase',
                }}>{tier}</span>
              </div>

              {activeKey ? (
                <div>
                  <div style={{fontFamily: 'var(--font-mono)', fontSize: 14, color: 'var(--muted)'}}>
                    usnap_k_{activeKey.key_prefix}••••••••
                  </div>
                  <p className="muted" style={{fontSize: 13, margin: '8px 0 0'}}>
                    Created {new Date(activeKey.created_at).toLocaleDateString()}
                    {activeKey.label && <span> · {activeKey.label}</span>}
                  </p>
                </div>
              ) : (
                <p className="muted">No active API key. Contact support if you need help.</p>
              )}
            </div>

            {/* Plan Card */}
            <div style={{
              background: tier === 'pro' ? 'var(--c-yellow)' : 'var(--bg)',
              border: '2px solid var(--fg)',
              borderRadius: 16, padding: 28
            }}>
              <h2 style={{margin: '0 0 8px', fontSize: 20}}>
                {tier === 'pro' ? 'Pro Plan' : 'Free Plan'}
              </h2>
              <p className="muted" style={{margin: '0 0 16px', fontSize: 14}}>
                {tier === 'pro'
                  ? 'Unlimited links, 50k webhooks/mo, 60 req/min.'
                  : '100 links/mo, 1k webhooks/mo, 10 req/min.'
                }
              </p>
              {tier === 'pro' ? (
                <button className="btn btn--secondary btn--sm" onClick={handleManageBilling}>
                  Manage billing 
                </button>
              ) : (
                <button className="btn btn--primary btn--sm" onClick={handleUpgrade}>
                  Upgrade to Pro  $19/mo 
                </button>
              )}
            </div>

            {/* Quick Links */}
            <div style={{display: 'flex', gap: 12, flexWrap: 'wrap'}}>
              <a className="btn btn--ghost btn--sm" href="/docs/quickstart/">Quickstart</a>
              <a className="btn btn--ghost btn--sm" href="/docs/api-reference/">API reference</a>
              <a className="btn btn--ghost btn--sm" href="https://usnp.me/docs" target="_blank" rel="noreferrer">Swagger UI</a>
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

Object.assign(window, { Dashboard });
  • [ ] Step 2: Add script tag to index.html

In landing/index.html, add the dashboard script after login.jsx:

  <script type="text/babel" src="dashboard.jsx"></script>

The final script order:

  <script type="text/babel" src="components.jsx"></script>
  <script type="text/babel" src="landing.jsx"></script>
  <script type="text/babel" src="signup.jsx"></script>
  <script type="text/babel" src="login.jsx"></script>
  <script type="text/babel" src="dashboard.jsx"></script>
  <script type="text/babel" src="pricing.jsx"></script>
  <script type="text/babel" src="app.jsx"></script>
  • [ ] Step 3: Commit
git add landing/dashboard.jsx landing/index.html
git commit -m "feat: add dashboard component with API key, plan, and billing"

Task 11: Update App Router and Nav for Auth State

Wire up the Supabase client, auth state management, and new routes in app.jsx. Update nav in components.jsx to show Dashboard/Log out when logged in.

Files: - Modify: landing/app.jsx - Modify: landing/components.jsx

  • [ ] Step 1: Rewrite app.jsx

Replace the entire contents of landing/app.jsx:

/* Landing site app with auth */
const { useState, useEffect } = React;

// Initialize Supabase client
// These values are public (anon key) — safe to expose in client-side code
const SUPABASE_URL = 'https://YOUR_PROJECT_REF.supabase.co';  // TODO: replace with real project URL
const SUPABASE_ANON_KEY = 'YOUR_ANON_KEY';  // TODO: replace with real anon key
const supabaseClient = window.supabase.createClient(SUPABASE_URL, SUPABASE_ANON_KEY);

function App() {
  const [route, setRoute] = useState(() => {
    const h = (location.hash || '').slice(1).split('?')[0];
    return ['landing', 'signup', 'login', 'dashboard', 'pricing'].includes(h) ? h : 'landing';
  });
  const [session, setSession] = useState(null);
  const [authLoading, setAuthLoading] = useState(true);

  // Listen for auth state changes
  useEffect(() => {
    supabaseClient.auth.getSession().then(({ data: { session } }) => {
      setSession(session);
      setAuthLoading(false);
      // Auto-redirect to dashboard if logged in and on auth pages
      if (session && ['login', 'signup'].includes(route)) {
        setRoute('dashboard');
      }
    });

    const { data: { subscription } } = supabaseClient.auth.onAuthStateChange((_event, session) => {
      setSession(session);
      if (!session && route === 'dashboard') {
        setRoute('landing');
      }
    });

    return () => subscription.unsubscribe();
  }, []);

  useEffect(() => {
    location.hash = route;
    window.scrollTo({top: 0, behavior: 'instant'});
  }, [route]);

  useEffect(() => {
    document.documentElement.dataset.theme = 'light';
    document.documentElement.style.setProperty('--wonky', '1.2deg');
  }, []);

  const go = (id) => setRoute(id);

  if (authLoading) {
    return (
      <div style={{display: 'grid', placeItems: 'center', minHeight: '100vh'}}>
        <div className="muted">Loading</div>
      </div>
    );
  }

  return (
    <div data-screen-label={`yousnap.me — ${route}`}>
      <Nav route={route} go={go} session={session} supabase={supabaseClient}/>
      <div className="page">
        {route === 'landing' && <Landing go={go}/>}
        {route === 'signup' && <SignUp go={go} supabase={supabaseClient} onAuth={setSession}/>}
        {route === 'login' && <Login go={go} supabase={supabaseClient}/>}
        {route === 'dashboard' && <Dashboard go={go} supabase={supabaseClient} session={session}/>}
        {route === 'pricing' && <Pricing go={go}/>}
      </div>
      <Footer go={go}/>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('app')).render(<App/>);

Important: The SUPABASE_URL and SUPABASE_ANON_KEY placeholders need to be replaced with the real Supabase project values. Get these from the Supabase MCP get_project_url and get_publishable_keys tools.

  • [ ] Step 2: Update Nav in components.jsx

Replace the Nav function in landing/components.jsx:

function Nav({ route, go, session, supabase }) {
  const links = [
    { id: 'landing', label: 'Home' },
    { id: 'pricing', label: 'Pricing' },
    { id: 'docs',    label: 'Docs', href: '/docs/' },
  ];
  return (
    <div className="nav">
      <div className="container nav__row">
        <div className="nav__left">
          <Logo onClick={() => go('landing')} />
        </div>
        <div className="nav__right">
          <div className="nav__links">
            {links.map(l => (
              l.href ? (
                <a
                  key={l.id}
                  className="nav__link"
                  href={l.href}
                >{l.label}</a>
              ) : (
                <div
                  key={l.id}
                  className={'nav__link' + (route === l.id ? ' nav__link--active' : '')}
                  onClick={() => go(l.id)}
                >{l.label}</div>
              )
            ))}
          </div>
          {session ? (
            <div style={{display: 'flex', gap: 10}}>
              <button className="btn btn--primary btn--sm" onClick={() => go('dashboard')}>Dashboard</button>
              <button className="btn btn--ghost btn--sm" onClick={async () => {
                await supabase.auth.signOut();
                go('landing');
              }}>Log out</button>
            </div>
          ) : (
            <div style={{display: 'flex', gap: 10}}>
              <button className="btn btn--ghost btn--sm" onClick={() => go('login')}>Log in</button>
              <button className="btn btn--primary btn--sm" onClick={() => go('signup')}>Get API key</button>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}
  • [ ] Step 3: Commit
git add landing/app.jsx landing/components.jsx
git commit -m "feat: add auth state management, login/dashboard routes, nav auth toggle"

Task 12: Update Landing Page Middleware for POST Requests

The landing page middleware currently only intercepts GET requests on yousnap.me. POST requests to /api-keys, /checkout, /billing-portal, /stripe-webhook from the dashboard on yousnap.me need to pass through to the API. Currently this already works because the middleware only intercepts GET, and the dashboard calls usnp.me directly. No change needed.

However, the landing page middleware needs to serve the new .jsx files. Verify .jsx is in CONTENT_TYPES — it already is (line 166: ".jsx": "application/javascript"). No changes needed.

This task is a no-op verification. Skip to Task 13.


Task 13: Wire Real Supabase Credentials

Replace the placeholder Supabase URL and anon key in app.jsx with the real values from the Supabase project.

Files: - Modify: landing/app.jsx

  • [ ] Step 1: Get Supabase project URL and anon key

Use the Supabase MCP tools: - get_project_url → get the API URL - get_publishable_keys → get the anon key

The Supabase URL for auth is the project URL (e.g. https://XXXX.supabase.co).

  • [ ] Step 2: Update app.jsx with real values

Replace the placeholder lines in landing/app.jsx:

const SUPABASE_URL = 'https://REAL_PROJECT_REF.supabase.co';
const SUPABASE_ANON_KEY = 'REAL_ANON_KEY_HERE';
  • [ ] Step 3: Commit
git add landing/app.jsx
git commit -m "feat: wire real Supabase credentials into dashboard client"

Task 14: Set Environment Variables for Stripe

The backend needs SUPABASE_JWT_SECRET, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and STRIPE_PRICE_ID environment variables.

  • [ ] Step 1: Get Supabase JWT secret

In the Supabase dashboard, go to Settings → API → JWT Secret. This is the SUPABASE_JWT_SECRET.

  • [ ] Step 2: Get Stripe keys

In the Stripe Dashboard: - STRIPE_SECRET_KEY — from Developers → API keys (use test mode first) - Create a product and price for Pro tier ($19/month) → STRIPE_PRICE_ID - Create a webhook endpoint pointing to https://usnp.me/stripe-webhook for events checkout.session.completed and customer.subscription.deletedSTRIPE_WEBHOOK_SECRET

  • [ ] Step 3: Add to DigitalOcean App Platform

Add these env vars to the DigitalOcean app spec or via the dashboard. This is a manual step — do not push to production without user permission.


Task 15: Full Test Suite Verification

Run the complete test suite and verify everything passes.

Files: - All test files

  • [ ] Step 1: Run full test suite

Run: pytest -v --tb=short Expected: All tests pass (existing + new JWT auth tests + new Stripe tests).

  • [ ] Step 2: Fix any failures

Address any test failures. Common issues: - Existing tests that send X-API-Key to POST /api-keys or GET /api-keys should still work since those endpoints now accept both auth methods. - CORS-related test changes if any tests check CORS headers.

  • [ ] Step 3: Final commit
git add -A
git commit -m "test: verify full test suite passes with signup and Stripe integration"