Metadata-Version: 2.4
Name: tidepool
Version: 0.1.0
Summary: Deploy full-featured web apps in minutes. Auth, payments, admin, email, database — one import.
License-Expression: MIT
Project-URL: Homepage, https://tidepool.sh
Project-URL: Documentation, https://tidepool.sh/api
Project-URL: Repository, https://github.com/tidepoolsh/tidepool
Keywords: paas,web,deploy,cli,ai
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.28
Requires-Dist: argon2-cffi>=21.0
Requires-Dist: jinja2>=3.1
Requires-Dist: itsdangerous>=2.0
Requires-Dist: markdown>=3.4
Dynamic: license-file

# Tidepool

```
    ┌─────────────────────────────────────────────┐
    │  build locally  →  deploy in 30s  →  live   │
    │       ↑                                ↓    │
    │    iterate        auth · payments · email    │
    │       ↑              db · files · http       │
    │       └──────────  <you>.tidepool.sh  ──┘   │
    └─────────────────────────────────────────────┘
```

A PaaS platform that is build for AI agents (and humans with interactive CLI coding tools like Claude Code). The API is meant to be self-documenting, and every feature is available first and foremost via the command line.

Tools such as auth flows, Stripe payments, admin and database ORM, multi-page routing, file storage, email, a key-value database, background tasks, etc are all built and documented with AI agents in mind. These tools are available via a single `tp` object; they are meant to provide abstractions for infrastructure that is hard for an AI to set up (eg, third party subscriptions that take lots of clicks/config). We abstract these away so that the AI can move fast in terms of core site design, logic, and styling. Logging and API errors/warnings/tips are meant to be as detailed as possible so that an agent, on its own, can quickly understand and solve whatever problems come up.

Pods (which are always-on Fly machines at `<name>.tidepool.sh`) scale horizontally to 10+ replicas with shared Postgres/Redis/R2 — enough for a Substack clone at 20k–50k DAU or a Reddit-style site at 5k–15k DAU. The goal: a membership site, a SaaS dashboard, or a social platform in an afternoon. No servers, no Docker, no AWS. Stack: Django, Redis, Postgres, Fly Machines, Cloudflare R2, Stripe, Porkbun.

## Quickstart

### Install

```bash
pip install tidepool
```

### Init a pod

```bash
tidepool init my-blog
cd my-blog
```

Creates a directory with a default `main.py`:
```python
import tp
tp.page('/', '<h1>Hello from Tidepool!</h1>')
```

### Develop locally

```bash
tidepool dev
# Pod runs at http://localhost:8000
```

The dev server replicates production pod behavior: file I/O goes to `./tp_data/files/`, `tp.db` persists to a JSON file, `tp.state` is readable at `?format=json`. Stripe, R2, and email are optional — the app runs without them.

### Deploy

```bash
tidepool --url https://tidepool.sh register --email you@example.com
# verify email, then:
tidepool deploy
# Pod is live at https://my-blog.tidepool.sh in ~30 seconds
```

`deploy` auto-discovers source files and uploads `tp_data/files/` (images, media) and `tp_data/secrets.json` automatically.

### Push & Pull

Pull a live pod to develop locally — all state comes with it:

```bash
tidepool pull abc123
# Creates my-blog/ with source files + tp_data/ (db, secrets, files)
cd my-blog
tidepool dev
# Edit code, add data, test locally...
tidepool push              # pushes everything back (hash remembered from pull)
```

`pull` downloads source files, `tp.db` → `tp_data/db.json`, `tp.secrets` → `tp_data/secrets.json`, and pod files → `tp_data/files/`. The dev server reads all of these natively — no conversion needed.

`push` auto-discovers source files (same as deploy) and uploads them along with `tp_data/db.json` (merge by default), `tp_data/secrets.json`, and all files in `tp_data/files/`. Pushing source files triggers a pod restart. Use `-y` to skip the confirmation prompt.

```bash
tidepool pull abc123 --dir .   # pull into current directory instead of a subdirectory
tidepool push abc123           # explicit hash (overrides remembered hash)
tidepool push --file main.py   # push specific files instead of auto-discover
tidepool push --secret STRIPE_KEY=sk_xxx               # override a secret
tidepool push --replace-db     # replace all db keys instead of merging
tidepool push --sync           # delete remote files not present locally
tidepool push -y               # skip confirmation prompt
```

### .tpignore

Create a `.tpignore` file to exclude files from `deploy` and `push` (same syntax as `.gitignore`):

```
# Directories (trailing slash)
data/
notebooks/

# File patterns
*.csv
*.npy
*.pkl
*.parquet
pipeline.py
build_log.*
```

Always ignored regardless of `.tpignore`: `tp_data/`, `__pycache__/`, `.git/`, `venv/`, `.venv/`, `node_modules/`, dotfiles, and `build_log.*`. Files over 50MB are skipped automatically with a warning.

`tidepool init` generates a starter `.tpignore`. For existing projects, create one before your first `deploy` or `push` to avoid uploading data files, models, or build artifacts.

### Eject Mode

For full control over the runtime, eject the internals into your project:

```bash
tidepool eject
# Copies tp_runtime.py, tp_server.py, tp_backend.py, tp_templates/ into your project
```

These files are now yours to modify. `tidepool dev`, `deploy`, and `push` auto-detect eject mode when `tp_server.py` exists in the project directory — no flags needed. To undo, delete the ejected files.

## Runtime Tools

Use `import tp` at the top of every `.py` file. `main.py` runs once at startup to configure the pod — set auth, payments, seed data, register routes. The server dispatches requests directly to handlers.

| Name | Description | Usage |
| ---- | ----------- | ----- |
| `tp.route` | Register a request handler with path params | `@tp.route('/post/:slug', methods=['GET'])` |
| `tp.page` | Register a static HTML page (no handler) | `tp.page('/about', '<h1>About</h1>')` |
| `tp.auth` | Full auth system. Presets: `'paywall'` (pay-first + magic link) or `'standard'` (email/password) | `tp.auth = 'paywall'` or `tp.auth = 'standard'` |
| `tp.payments` | Stripe subscriptions and one-time purchases (in cents) | `tp.payments = {products: [{id: 'pro', price: 500, recurring: 'month'}]}` |
| `tp.admin` | Auto-generated admin panel at `/_admin/` | `tp.admin = {users: ['admin@example.com']}` |
| `tp.create_user` | Create user with hashed password (idempotent) | `tp.create_user('sam@x.com', 'pass', subscriptions={'pro': True})` |
| `tp.db` | Key-value store, 1GB limit, persisted across runs | `tp.db.set('post:slug', {...})` / `tp.db.get('post:slug')` |
| `tp.files` | File storage (R2 in prod, 50GB), served at `/_files/` | `tp.files.write('photo.jpg', data)` / `tp.files.read('photo.jpg')` |
| `tp.email` | Send email with optional HTML and attachments | `tp.email('user@x.com', 'Subject', 'body', html='<p>hi</p>')` |
| `tp.http` | HTTP client (same API as `requests`), 200 req/60s, SSRF-protected | `tp.http.post(url, json=payload, headers={...})` |
| `tp.markdown` | Convert markdown to HTML (tables, code, footnotes) | `html = tp.markdown('# Hello\n\nWorld')` |
| `tp.secrets` | Read-only dict of deploy-time credentials | `api_key = tp.secrets['STRIPE_KEY']` |
| `tp.state` | Public app state dict, readable at `?format=json` | `tp.state = {'status': 'live'}` |
| `tp.publish` | Update public JSON state (ETag-supported polling) | `tp.publish({'messages': msgs})` |
| `tp.background` | Background tasks (max 5). `seconds<=0`: once, `>0`: loop | `@tp.background(seconds=3600)` |

**Handler return values:** `str` → 200 HTML, `dict`/`list` → 200 JSON, `int` → status code, `tuple(body, status)` → body + status, `None` → 303 redirect, `generator` → SSE stream.

**Request object:** Handler receives `(req, **params)`. Attributes: `req.path`, `req.method`, `req.query`, `req.user` (dict or None), `req.body` (dict), `req.files` (dict of upload metadata — file data auto-saved to `tp.files`).

### Auth details

**Auth presets:** `tp.auth = 'paywall'` for payment-first apps (no signup form, accounts auto-created at checkout, magic link for return logins — pair with `tp.payments`). `tp.auth = 'standard'` for traditional email/password signup with confirmation, reset, and magic link. Customize after setting a preset: `tp.auth['required'] = ['/dash/*']`, `tp.auth['theme'] = {'accent': '#e74c3c'}`. Or pass a full dict for manual control: `tp.auth = {required: ['/dash/*'], signup: True, reset: True, oauth: ['google']}`.

Email confirmation on by default — `signup_confirm: False` to disable. `req.user` in handlers gives the logged-in user including subscriptions, purchases, and `avatar_url` (from Google profile). Theme: `theme: {page: '<html>...{content}...</html>'}` wraps auth pages in your layout; `{content}` receives the form, `{title}` the page title. Simpler: `theme: {accent: '#color', css: '...'}`.

**Google OAuth setup:** Add `google_client_id` and `google_client_secret` to `tp_data/secrets.json`. Get credentials at [Google Cloud Console](https://console.cloud.google.com/apis/credentials) → Create OAuth 2.0 Client ID (Web application). Add authorized redirect URI: `http://localhost:8000/_auth/oauth/google/callback` for dev, `https://yourdomain.com/_auth/oauth/google/callback` for prod. That's it — the server handles the rest.

### Payments details

Set `tp.payments = {products: [{id: 'pro', name: 'Pro', price: 500, recurring: 'month'}]}`. Users pay at `/_pay/pro`, manage subscriptions at `/_pay/portal`. `recurring: 'month'`/`'year'` for subscriptions; omit for one-time. Dev mode simulates purchases instantly. Prod requires `tidepool stripe-connect` (one-time setup).

### Admin details

Set `tp.admin = {models: {post: {fields: {title: 'string', body: 'text', tier: 'choice:free,pro'}, display: ['title']}}}`. Field types: `string`, `text`, `bool`, `number`, `date`, `choice:a,b,c`. Plus read-only views of users, payments, emails, files. If not set, auto-inferred from `tp.db` key patterns. Admin access control: with `tp.auth` configured, set `users: ['admin@example.com']` to restrict to specific emails (otherwise any logged-in user can access). Without `tp.auth`, admin is open in dev and key-gated in prod (key printed in server logs at startup, access via `/_admin?key=<key>`).

### Background tasks

```python
@tp.background()  # runs once at startup
def migrate(tp):
    if not tp.db.get('_migrated_v2'):
        for key, val in tp.db.prefix('post:'):
            val['version'] = 2
            tp.db.set(key, val)
        tp.db.set('_migrated_v2', True)

@tp.background(seconds=3600)  # every hour
def send_digest(tp):
    for email, user in tp.users().items():
        if user.get('subscriptions', {}).get('digest'):
            posts = tp.db.prefix('post:', reverse=True, limit=5)
            tp.email(email, 'Hourly Digest', '\n'.join(t for _, t in posts))
```

### Server-Sent Events (SSE)

Return a generator from any route handler to stream real-time events:

```python
@tp.route('/feed/live')
def live_feed(req):
    def stream():
        last_count = 0
        while True:
            messages = tp.db.prefix('msg:', reverse=True, limit=20)
            if len(messages) != last_count:
                last_count = len(messages)
                yield {'messages': [m for _, m in messages]}
            time.sleep(2)
    return stream()
```

Client-side: `new EventSource('/feed/live')`. Max 100 concurrent SSE connections per pod.

### Static files & templating

- **Static files** — Files in `static/` alongside `main.py` are served at `/static/<path>`.
- **Jinja2** — Pre-installed. `from jinja2 import Environment, FileSystemLoader; env = Environment(loader=FileSystemLoader('templates'), autoescape=True)`. Render: `env.get_template('page.html').render(posts=p)`.

| Group       | Subcategory    | Lines | Files                                                              |
| ----------- | -------------- | ----: | ------------------------------------------------------------------ |
| **SDK**     | Runtime        |   446 | tp_runtime.py (310), tp_backend.py (136)                           |
|             | CLI            |   585 | cli.py                                                             |
|             | Dev Server     | 1,052 | tp_server.py                                                       |
|             |                | 2,083 |                                                                    |
| **Core**    | Views & API    |   900 | views.py                                                           |
|             | Engine         | 1,205 | domains (439), models (155), machines (155), backend_prod (132),   |
|             |                |       | billing (128), storage (77), tasks (63), middleware (42), apps (14) |
|             | Admin          |   427 | pod_admin (322), admin (105)                                       |
|             | Frontend       |   670 | templates/ (7 HTML files)                                          |
|             | Config & Mgmt  |   335 | config/ (161), management commands (174)                           |
|             |                | 3,537 |                                                                    |
| **Tests**   | SDK            |   430 | test_runtime.py                                                    |
|             | Core           |   700 | test_api.py                                                        |
|             |                | 1,130 |                                                                    |
| **Infra**   |                |   308 | fly.toml (45), nginx.conf (59), scripts (85), pod_entrypoint (68), |
|             |                |       | Dockerfiles (41), manage.py (10)                                   |
| **Total**   |                | 7,058 |                                                                    |

## Repo structure

```
tidepool/
├── sdk/                 Open-source runtime layer
│   ├── tp_runtime.py    The tp object (route, page, auth, payments, db, files, email, etc.)
│   ├── tp_backend.py    Backend interface + LocalBackend (dev storage)
│   ├── tp_server.py     Unified server (runs in both dev and prod)
│   ├── tp_templates/    Auth form HTML templates
│   └── cli.py           CLI: init, dev, eject, deploy, push, pull, etc.
├── core/                Platform Django app
│   ├── models.py        Agent, Pod, Environment
│   ├── views.py         API + billing endpoints
│   ├── pod_admin.py     Framework-agnostic admin panel rendering
│   ├── backend_prod.py  ProdBackend (Postgres/R2 storage for pods)
│   ├── domains.py       Domain search (WHOIS), registration (Porkbun) + Fly cert management
│   ├── billing.py       Stripe checkout, autoreload, webhooks
│   ├── machines.py      Fly Machines API client (always-on)
│   ├── tasks.py         Daily credit deduction
│   ├── storage.py       R2 with local fallback
│   ├── middleware.py     Rate limiting
│   ├── admin.py         Django admin config
│   ├── apps.py          App config
│   ├── templates/       Landing, API docs, quickstart, pod page, billing
│   └── management/      scheduler, seed_environments, activate_agent, loadtest
├── config/              Django configuration (settings, urls, wsgi)
├── fly.toml             Control plane config (web + scheduler processes)
├── Dockerfile           Control plane container
├── Dockerfile.pod       Always-on pod container
├── pod_entrypoint.py    Pod boot: fetch code, start server
├── nginx.conf           Reverse proxy config (subdomain routing + load balancing)
├── entrypoint.sh        Multi-role entrypoint (web/scheduler/release)
├── run.sh               Local dev launcher
└── build.sh             Setup script (migrations, seed, superuser)
```

## Local dev

Prerequisites: Python 3.11+, Redis running locally (`brew install redis && redis-server`).

```bash
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python manage.py migrate
python manage.py seed_environments
./run.sh  # starts Django dev server (port 8000) + scheduler
```

Admin: http://localhost:8000/ — username `admin`, password `tidepool123` (or `SUPERUSER_PASSWORD` env var). To activate an agent without Stripe: `python manage.py activate_agent <hash>`.

Local dev uses SQLite and `./media/` for file storage. Stripe, R2, and email are all optional — the app runs without them.

## Deployment

Two Fly apps: `tidepool` (control plane: Django + scheduler) and `tidepool-pods` (always-on Machines, one per pod).

### First deploy

```bash
brew install flyctl && fly auth login

# 1. Create apps + Postgres + Redis
fly launch --copy-config --no-deploy
fly postgres create --name tidepool-db
fly postgres attach tidepool-db              # sets DATABASE_URL
fly redis create --name tidepool-redis       # sets REDIS_URL
fly apps create tidepool-pods

# 2. Generate secrets
fly tokens create org <your-org>             # org token (not deploy token!)
python -c 'import secrets; print(secrets.token_hex(32))'  # FLY_INTERNAL_SECRET

# 3. Set secrets on the control plane
fly secrets set \
  DJANGO_SECRET_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(50))')" \
  SUPERUSER_PASSWORD="your-admin-password" \
  FLY_API_TOKEN="<org token>" \
  FLY_INTERNAL_SECRET="<hex>" \
  CONTROL_PLANE_URL="https://tidepool.fly.dev" \
  FLY_PODS_APP="tidepool-pods"

# 4. Build and push the pod image
fly deploy -a tidepool-pods --dockerfile Dockerfile.pod --no-cache
# This creates a temporary machine — destroy it after the image is pushed:
fly machines list -a tidepool-pods
fly machines destroy <machine-id> --force -a tidepool-pods

# 5. Deploy the control plane
fly deploy
```

### Automated deploys (default)

Pushing to `main` triggers the GitHub Actions workflow (`.github/workflows/fly-deploy.yml`). This is the default deployment method.

1. **Control plane** — always deployed via `flyctl deploy --remote-only`.
2. **Pod image** — rebuilt and pushed to `registry.fly.io/tidepool-pods:latest` only when pod-relevant files change (`sdk/`, `core/`, `config/`, `pod_entrypoint.py`, `manage.py`, `Dockerfile.pod`, `requirements-pod.txt`, `.github/workflows/`).

Running pods pick up the new image on next restart (kill + activate, or new machine creation).

**Required GitHub secret:** `FLY_API_TOKEN` (set in repo Settings → Secrets → Actions).

### Manual deploys

For one-off deploys or debugging, you can deploy manually from the command line.

```bash
# Control plane:
fly deploy

# Pod image (builds remotely, pushes to registry):
fly deploy -a tidepool-pods --dockerfile Dockerfile.pod --no-cache
# This creates a temporary machine — destroy it after the image is pushed:
fly machines list -a tidepool-pods
fly machines destroy <machine-id> --force -a tidepool-pods

# Restart a running pod to pick up the new image:
# Kill + reactivate via API, or the pod picks up registry.fly.io/tidepool-pods:latest on next machine creation.
```

### Custom domain

```bash
fly certs add tidepool.sh -a tidepool
fly certs add "*.tidepool.sh" -a tidepool    # wildcard for pod subdomains

# In Cloudflare DNS (DNS-only mode):
#   A    @  → <fly ipv4>
#   AAAA @  → <fly ipv6>
#   A    *  → <fly ipv4>
#   AAAA *  → <fly ipv6>
#   CNAME _acme-challenge → <app>.flydns.net

fly secrets set CONTROL_PLANE_URL="https://tidepool.sh"
```

### Email setup (Resend)

```bash
# Add + verify domain at resend.com, add DNS records in Cloudflare:
# TXT resend._domainkey → DKIM key
# TXT send → SPF record
# MX send → feedback-smtp.us-east-1.amazonses.com

fly secrets set \
  EMAIL_HOST_PASSWORD="re_YOUR_RESEND_API_KEY" \
  DEFAULT_FROM_EMAIL="noreply@tidepool.sh"
```

### Optional secrets

```bash
# Stripe (billing)
fly secrets set STRIPE_SECRET_KEY="sk_..." STRIPE_WEBHOOK_SECRET="whsec_..."

# R2 (file storage — falls back to local without these)
fly secrets set R2_ACCESS_KEY="..." R2_SECRET_KEY="..." R2_BUCKET="tidepool" R2_ENDPOINT="https://...r2.cloudflarestorage.com"

# Google OAuth (for pod auth flows — pods read from their own tp_data/secrets.json)
# These env vars are fallbacks if secrets.json doesn't have google_client_id / google_client_secret
fly secrets set GOOGLE_OAUTH_CLIENT_ID="..." GOOGLE_OAUTH_CLIENT_SECRET="..."

# Porkbun (domain registration for pods)
fly secrets set PORKBUN_API_KEY="pk1_..." PORKBUN_API_SECRET="sk1_..."
```

### Gotchas

- **Token type matters.** The Machines API needs an org token (`fly tokens create org`), not a deploy token. Wrong token → 401 on pod creation.
- **Pod image deploy creates temp machines.** Manual `fly deploy -a tidepool-pods` creates a fly-managed machine. Destroy it after — pod machines are created by the control plane via the Machines API. The CI workflow avoids this by using `flyctl auth docker` + `docker build` + `docker push`.

## Technical Notes

- No DRF. `JsonResponse` + `@csrf_exempt` function views + `api_auth` decorator.
- IDs are 8-char a-z0-9 hashes for Agent, Pod, and Environment. Retry on collision.
- Pod code is multi-file: `files` JSONField stores `[{"name": "main.py", "content": "..."}]`. Binary files use `{"encoding": "base64"}`. Subdirectory paths are preserved.
- Always-on architecture: each pod is a persistent Fly Machine running `sdk/tp_server.py`. `main.py` runs once at startup to register routes (`@tp.route`), static pages (`tp.page`), auth, payments, seed data. The server dispatches requests directly to handlers — main.py is never re-executed. Pod image is ~510MB (33 pre-installed Python packages); ~7.4GB ephemeral scratch disk per machine.
- Backend interface: `sdk/tp_backend.py` defines a storage abstraction (db, files, secrets, email, sessions). `LocalBackend` uses JSON files in `tp_data/` for dev. `core/backend_prod.py` uses Postgres/R2/Redis for prod. Secrets are read-only at runtime; state and users/purchases are stored as db rows for concurrent safety.
- Horizontal scaling: each pod supports 1–10 replicas (Fly Machines). Set via `PATCH /api/pods/<hash> {replicas: N}` or +/- buttons on the billing page. Traffic routes to the first machine; proper load balancing is a future improvement. Each replica costs the same as the base pod.
- Subdomain routing: nginx reverse proxy routes `*.tidepool.sh` to pod Machines via Fly internal networking. Route map files (`routes_subdomain.map`, `routes_domain.map`) map hostnames directly to machine addresses. DNS resolved at request time (no upstream blocks), so dead machines get a per-request 502 instead of crashing nginx. Wildcard DNS + wildcard TLS cert, no per-pod setup.
- Credit deduction is time-based: ~33 credits/day per machine depending on environment. Deducted daily. 1,000 credits = $20. A 3-replica pod costs 3x.
- 200 free credits on email verification (~6 days of a standard pod). After that, $20 = 1,000 credits. When credits hit 0, pods are paused (Machines destroyed) and data preserved.
- Autoreload: charges card on file for $20 when balance < 100 credits. Exponential backoff on failure.
- Billing page: authenticated via API key (POST form → signed cookie). All billing actions also available via REST API with Bearer token.
- Pod statuses: active, paused (credits exhausted), error, killed.
- Rate limits: 200 HTTP requests per 60s sliding window, 5 emails per handler call + 100/day per pod, 10 pod creates/hour + 100/day per agent.
- Route handlers: `@tp.route('/post/:slug')` registers a handler that receives `(req, slug)`. The server matches paths and dispatches directly — no re-execution.
- File uploads: multipart POST data is auto-saved to `tp.files` and served at `/_files/<name>`. `req.files` in route handlers contains uploaded file metadata.
- Per-user state: `req.user` in route handlers contains the authenticated user's session, including `subscriptions` and `purchased` dicts. Gate content by tier with a single dict lookup.
- Stripe customer portal: `/_pay/portal` redirects authenticated users to Stripe's self-service portal. Zero code required.
- All pod state is in Postgres and R2 (users, purchases, db, state, files). Pod Machines can be recreated from stored state at any time. `tp.secrets` are set at deploy time and loaded read-only.
- Auto-inferred admin: if `tp.admin` is not set, the runtime scans `tp.db` for `model:id` key patterns and auto-generates an admin panel config.
