diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..72305ce --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,230 @@ +# SimpleTip — Installation Guide + +Run your own SimpleTip tipping node. This guide covers everything from database setup to production deployment. + +## Requirements + +- **Python** 3.11+ (3.12 recommended) +- **PostgreSQL** 15+ with `pgcrypto` extension +- **Stripe account** (for real payments — optional for demo mode) +- **Reverse proxy** (nginx, Caddy, or similar — for HTTPS) + +## 1. Database Setup + +Create a Postgres database and user: + +```sql +-- Run as a Postgres superuser +CREATE USER simpletip WITH PASSWORD 'your-secure-password'; +CREATE DATABASE simpletip OWNER simpletip; +``` + +Apply the schema: + +```bash +psql -h -U simpletip -d simpletip -f backend/schema.sql +``` + +This creates all 15 tables (receivers, wallets, tips, etc.) with indexes. + +## 2. Generate Encryption Key + +Payout method details (bank accounts, crypto addresses, phone numbers) are encrypted at rest using AES-256-GCM. Generate a key: + +```bash +python3 -c "import secrets; print(secrets.token_hex(32))" +``` + +Save this key securely. If you lose it, encrypted payout details become unrecoverable. + +## 3. Backend Setup + +```bash +cd backend +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +### Environment Variables + +Copy `.env.example` to `.env` and fill in values: + +```bash +cp .env.example .env +``` + +| Variable | Required | Description | +|----------|----------|-------------| +| `DATABASE_URL` | Yes | `postgres://simpletip:pass@host:5432/simpletip` | +| `ENCRYPTION_KEY` | Yes | 64-char hex string from step 2 | +| `PORT` | No | Backend port (default: 8046) | +| `SIMPLETIP_NODE_URL` | No | Public URL of your node | +| `SIMPLETIP_NODE_NAME` | No | Display name (default: `SimpleTip by LinkedTrust`) | +| `DEMO_MODE` | No | Set to `true` to force demo mode even with Stripe keys | +| `STRIPE_SECRET_KEY` | No | Stripe secret key (`sk_test_...` or `sk_live_...`) | +| `STRIPE_PUBLISHABLE_KEY` | No | Stripe publishable key (`pk_test_...` or `pk_live_...`) | +| `STRIPE_WEBHOOK_SECRET` | No | Stripe webhook signing secret (`whsec_...`) | + +**Demo mode**: If no Stripe keys are configured (or `DEMO_MODE=true`), the backend runs in demo mode — all payments are simulated. Good for development and testing. + +### Run locally + +```bash +source venv/bin/activate +uvicorn app:app --host 0.0.0.0 --port 8046 +``` + +Or with auto-reload for development: + +```bash +uvicorn app:app --host 0.0.0.0 --port 8046 --reload +``` + +## 4. Stripe Configuration (for real payments) + +1. Create a Stripe account at https://stripe.com +2. Get your API keys from the Stripe Dashboard → Developers → API keys +3. Set `STRIPE_SECRET_KEY` and `STRIPE_PUBLISHABLE_KEY` +4. Set up a webhook endpoint: + - URL: `https://your-domain.com/api/webhook/stripe` + - Events: `checkout.session.completed` + - Copy the signing secret to `STRIPE_WEBHOOK_SECRET` + +Test mode keys (`sk_test_...`) work with test card numbers. Switch to live keys when ready. + +## 5. Reverse Proxy (nginx) + +Example nginx config for path-prefix deployment: + +```nginx +location /simpletip/ { + proxy_pass http://127.0.0.1:8046/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +Or for a dedicated domain: + +```nginx +server { + listen 80; + server_name tips.yourdomain.com; + + location / { + proxy_pass http://127.0.0.1:8046; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +## 6. systemd Service + +Create `/etc/systemd/system/simpletip.service`: + +```ini +[Unit] +Description=SimpleTip tipping backend +After=network.target + +[Service] +Type=simple +WorkingDirectory=/path/to/simpletip/backend +ExecStart=/path/to/simpletip/backend/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8046 +Restart=on-failure +RestartSec=5 +EnvironmentFile=/path/to/simpletip/backend/.env +# Or set directly: +# Environment=DATABASE_URL=postgres://simpletip:pass@localhost:5432/simpletip +# Environment=ENCRYPTION_KEY= +# Environment=SIMPLETIP_NODE_URL=https://tips.yourdomain.com +# Environment=SIMPLETIP_NODE_NAME=Your Node Name +# Environment=STRIPE_SECRET_KEY=sk_live_... +# Environment=STRIPE_PUBLISHABLE_KEY=pk_live_... +# Environment=STRIPE_WEBHOOK_SECRET=whsec_... + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable simpletip +sudo systemctl start simpletip +``` + +## 7. Embed the Widget + +Once your node is running and you've registered receivers via the setup page or API: + +```html + + + +``` + +Split tips between two receivers: + +```html + + +``` + +## API Quick Reference + +### Register a receiver +```bash +curl -X POST https://your-node/api/receiver/register \ + -H 'Content-Type: application/json' \ + -d '{"name":"Jane Doe","email":"jane@example.com","type":"individual"}' +``` + +### Add payout method +```bash +curl -X POST https://your-node/api/receiver/jane-doe/payout-method \ + -H 'Content-Type: application/json' \ + -d '{"methodType":"paypal","details":{"paypal_email":"jane@example.com"}}' +``` + +Available method types: `ach`, `zelle`, `venmo`, `paypal`, `moneygram`, `mpesa`, `usdt_eth`, `tether` + +### Check health +```bash +curl https://your-node/api/health +``` + +## Security Notes + +- **Encryption key**: Back it up. Losing it = losing access to all stored payout details. +- **Database**: Use a strong password. Restrict network access to the backend host only. +- **Stripe webhook**: Always set `STRIPE_WEBHOOK_SECRET` in production to verify webhook signatures. +- **HTTPS**: Required for production. Stripe won't work without it. +- **Admin endpoints**: `/api/admin/*` currently have no auth — add authentication before production use. +- **Production deployment**: Run on an isolated VM/server. Financial data should not share infrastructure with other applications. + +## Payout Method Fields + +Each method type requires specific fields in the `details` object: + +| Method | Fields | +|--------|--------| +| `ach` | `routing_number`, `account_number`, `account_holder_name`, `bank_name` | +| `zelle` | `email_or_phone` | +| `venmo` | `venmo_handle` | +| `paypal` | `paypal_email` | +| `moneygram` | `full_name`, `country`, `city`, `phone` | +| `mpesa` | `phone_number`, `country_code` | +| `usdt_eth` | `eth_address` | +| `tether` | `address`, `network` | diff --git a/README.md b/README.md index 26236c0..ae3e914 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Meanwhile, the people *in* the stories — the doctors, teachers, organizers, ac ```html - + ``` That's it. Works on Ghost, WordPress, Substack custom HTML, Hugo, Jekyll, raw HTML — anywhere JavaScript runs. @@ -44,8 +44,8 @@ The feature that makes SimpleTip different: when your article covers someone who ```html @@ -206,8 +206,8 @@ Full deployment docs coming. If you're interested in running a node, open an iss ## Tech Stack - **Web component:** Vanilla JS, Shadow DOM, zero dependencies. One file: `simpletip.js` -- **Backend:** Node.js + Express. Simple REST API. -- **Database:** SQLite (single node) or Postgres (production scale). +- **Backend:** Python 3.11+ / FastAPI / uvicorn. Async REST API with asyncpg connection pool. +- **Database:** PostgreSQL 15+ with pgcrypto. AES-256-GCM encryption for payout details. - **Payments in:** Stripe Checkout (cards, Apple Pay, Google Pay, bank), PayPal. - **Payments out:** PayPal Payouts, Wise API, Chimoney (M-Pesa), manual for others. - **Auth:** Anonymous-first — wallet auto-created on first tip. Optional email, Google OAuth, or ATProto OAuth for recovery and cross-device access. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..f029db4 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,20 @@ +# SimpleTip Backend Configuration +# Copy to .env and fill in values + +DATABASE_URL=postgres://simpletip:password@localhost:5432/simpletip +ENCRYPTION_KEY= # 64-char hex: python3 -c "import secrets; print(secrets.token_hex(32))" + +# Node identity +SIMPLETIP_NODE_NAME=SimpleTip by LinkedTrust +SIMPLETIP_NODE_URL=https://your-domain.com/simpletip + +# Stripe (omit for demo mode) +# STRIPE_SECRET_KEY=sk_test_... +# STRIPE_PUBLISHABLE_KEY=pk_test_... +# STRIPE_WEBHOOK_SECRET=whsec_... + +# Force demo mode even with Stripe keys +# DEMO_MODE=true + +# Port (default 8046) +# PORT=8046 diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..be8009b --- /dev/null +++ b/backend/app.py @@ -0,0 +1,1359 @@ +""" +SimpleTip Backend — FastAPI + +Port: 8046 +""" + +import asyncio +import logging +import secrets +from contextlib import asynccontextmanager +from typing import Optional +from urllib.parse import urlparse + +import stripe as stripe_lib +from fastapi import FastAPI, Header, HTTPException, Query, Request, Response +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +import db +import encryption +import atproto_oauth +from config import settings + +log = logging.getLogger("simpletip") + +# ── Stripe setup ───────────────────────────────────────────── + +if settings.stripe_enabled: + stripe_lib.api_key = settings.stripe_secret_key + + +# ── Lifespan ───────────────────────────────────────────────── + +@asynccontextmanager +async def lifespan(app: FastAPI): + await db.init_pool() + mode = "DEMO" if settings.is_demo else "LIVE" + print(f"SimpleTip backend started — {mode} mode") + print(f"DB: {settings.database_url.split('@')[-1] if '@' in settings.database_url else 'configured'}") + yield + await db.close_pool() + + +app = FastAPI(title="SimpleTip", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# ── Helpers ────────────────────────────────────────────────── + +def canonicalize_uri(url: str) -> str: + try: + p = urlparse(url) + path = p.path.rstrip("/") or "/" + return f"{p.scheme}://{p.netloc}{path}" + except Exception: + return url + + +async def get_wallet(authorization: Optional[str] = None): + """Extract wallet from Bearer token. Returns None if not authenticated.""" + if not authorization or not authorization.startswith("Bearer "): + return None + token = authorization[7:] + return await db.fetch_one("SELECT * FROM wallets WHERE token = $1", token) + + +def cents_to_dollars(c) -> float: + return int(c) / 100 + + +async def _publish_tip_safe(tip_id): + """Fire-and-forget ATProto publish — never raises.""" + try: + from atproto_publisher import publish_tip + async with db.pool.acquire() as conn: + await publish_tip(tip_id, conn) + except Exception: + log.exception("ATProto publish failed for tip %s (non-fatal)", tip_id) + + +# ── Pydantic models ────────────────────────────────────────── + +class ReceiverRegister(BaseModel): + name: str + email: str + type: str = "individual" + bio: str | None = None + wallet_token: str | None = None + +class PayoutMethodAdd(BaseModel): + method_type: str + details: dict + +class WalletCreate(BaseModel): + email: str | None = None + name: str | None = None + +class WalletContact(BaseModel): + email: str | None = None + name: str | None = None + phone: str | None = None + handle: str | None = None + display_name: str | None = None + anonymous: bool | None = None + +class FundRequest(BaseModel): + amount: float + method: str | None = None + +class TipSplit(BaseModel): + slug: str + pct: int + role: str = "primary" + +class TipRequest(BaseModel): + receivers: list[TipSplit] + amount: float + comment: str | None = None + page_url: str + +class PledgeRequest(BaseModel): + receivers: list[TipSplit] + amount: float + comment: str | None = None + page_url: str | None = None + +class WidgetLoad(BaseModel): + page_url: str + page_title: str | None = None + site_name: str | None = None + receivers: list[str] + +class RecoverRequest(BaseModel): + email: str + +class TipConfirm(BaseModel): + tip_id: str + +class AdminConfirmFunding(BaseModel): + fund_id: str + +class AdminConfirmPayout(BaseModel): + payout_id: str + provider_reference: str | None = None + + +# ── Health ─────────────────────────────────────────────────── + +@app.get("/api/health") +async def health(): + receivers = await db.fetch_val("SELECT count(*) FROM receivers") + tips = await db.fetch_val("SELECT count(*) FROM tips") + wallets = await db.fetch_val("SELECT count(*) FROM wallets") + return { + "status": "ok", + "node": settings.node_name, + "demoMode": settings.is_demo, + "receivers": receivers, + "tips": tips, + "wallets": wallets, + } + + +# ── Payment methods ────────────────────────────────────────── + +@app.get("/api/methods") +async def get_methods(): + methods = [] + if settings.stripe_enabled or settings.is_demo: + m = {"id": "stripe", "label": "Card / Apple Pay / Google Pay", "icon": "card"} + if settings.stripe_publishable_key: + m["publishableKey"] = settings.stripe_publishable_key + methods.append(m) + if settings.is_demo and not methods: + methods = [ + {"id": "stripe", "label": "Card / Apple Pay / Google Pay", "icon": "card"}, + {"id": "paypal", "label": "PayPal / Venmo", "icon": "paypal"}, + {"id": "zelle", "label": "Zelle", "icon": "zelle"}, + ] + return {"methods": methods, "demoMode": settings.is_demo} + + +# ── Receiver registration ─────────────────────────────────── + +PAYOUT_METHOD_FIELDS = { + "ach": ["routing_number", "account_number", "account_holder_name", "bank_name", "street_address"], + "zelle": ["email_or_phone"], + "venmo": ["venmo_handle"], + "paypal": ["paypal_email"], + "moneygram": ["full_name", "country", "city", "phone"], + "mpesa": ["phone_number", "country_code"], + "usdt_eth": ["eth_address"], + "tether": ["address", "network"], +} + + +@app.post("/api/receiver/register") +async def register_receiver(body: ReceiverRegister, authorization: Optional[str] = Header(None)): + import re + slug = re.sub(r"[^a-z0-9]+", "-", body.name.lower()).strip("-") + + existing = await db.fetch_one("SELECT slug, name, wallet_id FROM receivers WHERE email = $1", body.email) + if existing: + # If receiver exists but has no wallet, link one now + if not existing["wallet_id"]: + wallet = await get_wallet(authorization) + if wallet: + await db.execute("UPDATE receivers SET wallet_id = $1 WHERE email = $2", wallet["id"], body.email) + return {"slug": existing["slug"], "name": existing["name"], "existing": True} + + slug_check = await db.fetch_one("SELECT slug FROM receivers WHERE slug = $1", slug) + if slug_check: + count = await db.fetch_val("SELECT count(*) FROM receivers WHERE slug LIKE $1", slug + "%") + slug = f"{slug}-{count + 1}" + + # Link to existing wallet or create one + wallet = await get_wallet(authorization) + wallet_id = wallet["id"] if wallet else None + if not wallet_id: + # Create a wallet for this receiver + token = secrets.token_hex(32) + async with db.pool.acquire() as conn: + async with conn.transaction(): + w = await conn.fetchrow("INSERT INTO wallets (token) VALUES ($1) RETURNING id, token", token) + wallet_id = w["id"] + await conn.execute( + "INSERT INTO wallet_contacts (wallet_id, email, name, display_name) VALUES ($1, $2, $3, $4)", + wallet_id, body.email, body.name, body.name, + ) + + row = await db.fetch_one( + "INSERT INTO receivers (slug, name, email, type, bio, wallet_id) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, slug, name", + slug, body.name, body.email, body.type, body.bio, wallet_id, + ) + return dict(row) + + +@app.get("/api/receiver/{slug}") +async def get_receiver(slug: str): + r = await db.fetch_one( + "SELECT slug, name, type, bio, avatar_url, total_received_cents, created_at " + "FROM receivers WHERE slug = $1 AND status = 'active'", + slug, + ) + if not r: + raise HTTPException(404, "receiver not found") + tip_count = await db.fetch_val( + "SELECT count(*) FROM tip_splits WHERE receiver_id = (SELECT id FROM receivers WHERE slug = $1)", + slug, + ) + return {**dict(r), "tipCount": tip_count} + + +# ── Receiver payout methods ───────────────────────────────── + +@app.post("/api/receiver/{slug}/payout-method") +async def add_payout_method(slug: str, body: PayoutMethodAdd): + allowed = PAYOUT_METHOD_FIELDS.get(body.method_type) + if not allowed: + raise HTTPException(400, f"unknown method: {body.method_type}. allowed: {list(PAYOUT_METHOD_FIELDS)}") + + missing = [f for f in allowed if not body.details.get(f)] + if missing: + raise HTTPException(400, f"missing fields: {missing}") + + receiver = await db.fetch_one("SELECT id FROM receivers WHERE slug = $1", slug) + if not receiver: + raise HTTPException(404, "receiver not found") + + receiver_id = receiver["id"] + encrypted = encryption.encrypt(body.details) + + existing = await db.fetch_one( + "SELECT id FROM receiver_payout_methods WHERE receiver_id = $1 AND method_type = $2", + receiver_id, body.method_type, + ) + + if existing: + await db.execute( + "UPDATE receiver_payout_methods SET details_encrypted = $1 WHERE id = $2", + encrypted, existing["id"], + ) + return {"id": str(existing["id"]), "methodType": body.method_type, "updated": True} + + count = await db.fetch_val( + "SELECT count(*) FROM receiver_payout_methods WHERE receiver_id = $1", + receiver_id, + ) + is_first = count == 0 + + row = await db.fetch_one( + "INSERT INTO receiver_payout_methods (receiver_id, method_type, details_encrypted, is_preferred) " + "VALUES ($1, $2, $3, $4) RETURNING id", + receiver_id, body.method_type, encrypted, is_first, + ) + return {"id": str(row["id"]), "methodType": body.method_type, "isPreferred": is_first} + + +@app.get("/api/receiver/{slug}/payout-methods") +async def list_payout_methods(slug: str): + rows = await db.fetch_all( + "SELECT m.id, m.method_type, m.is_preferred, m.verified, m.details_encrypted, m.created_at " + "FROM receiver_payout_methods m JOIN receivers r ON r.id = m.receiver_id " + "WHERE r.slug = $1 ORDER BY m.is_preferred DESC, m.created_at", + slug, + ) + return [ + { + "id": str(r["id"]), + "methodType": r["method_type"], + "isPreferred": r["is_preferred"], + "verified": r["verified"], + "maskedDetails": encryption.masked_details(r["details_encrypted"]), + "createdAt": r["created_at"].isoformat(), + } + for r in rows + ] + + +# ── Widget load ────────────────────────────────────────────── + +@app.post("/api/widget/load") +async def widget_load(body: WidgetLoad, authorization: Optional[str] = Header(None)): + if not body.receivers: + raise HTTPException(400, "receivers[] required") + + # Validate receivers exist and have payout methods + placeholders = ", ".join(f"${i+1}" for i in range(len(body.receivers))) + rows = await db.fetch_all( + f"SELECT r.id, r.slug, r.name, r.avatar_url, " + f"(SELECT count(*) FROM receiver_payout_methods WHERE receiver_id = r.id) AS method_count " + f"FROM receivers r WHERE r.slug IN ({placeholders}) AND r.status = 'active'", + *body.receivers, + ) + + found_slugs = {r["slug"] for r in rows} + missing = [s for s in body.receivers if s not in found_slugs] + if missing: + raise HTTPException(404, f"unknown receivers: {missing}") + + no_payout = [r["slug"] for r in rows if r["method_count"] == 0] + if no_payout: + raise HTTPException(400, f"receivers without payout methods: {no_payout}") + + uri = canonicalize_uri(body.page_url) + + # Auto-create article + article = await db.fetch_one("SELECT id FROM articles WHERE uri = $1", uri) + if not article: + article = await db.fetch_one( + "INSERT INTO articles (uri, title, site_name) VALUES ($1, $2, $3) RETURNING id", + uri, body.page_title, body.site_name, + ) + article_id = article["id"] + + # Link receivers + for i, r in enumerate(rows): + role = "author" if i == 0 else "subject" + split_pct = 100 if len(rows) == 1 else (50 if i == 0 else 50 // (len(rows) - 1)) + await db.execute( + "INSERT INTO article_receivers (article_id, receiver_id, role, default_split_pct) " + "VALUES ($1, $2, $3, $4) ON CONFLICT (article_id, receiver_id) DO NOTHING", + article_id, r["id"], role, split_pct, + ) + + # Record impression + wallet = await get_wallet(authorization) + wallet_id = wallet["id"] if wallet else None + await db.execute( + "INSERT INTO article_events (article_id, event_type, wallet_id, metadata) " + "VALUES ($1, 'impression', $2, $3)", + article_id, wallet_id, "{}", + ) + + receiver_info = [{"slug": r["slug"], "name": r["name"], "avatarUrl": r["avatar_url"]} for r in rows] + wallet_status = ( + {"authenticated": True, "balance": int(wallet["balance_cents"]), "hasFunds": wallet["balance_cents"] > 0} + if wallet + else {"authenticated": False} + ) + + return {"articleId": str(article_id), "receivers": receiver_info, "wallet": wallet_status} + + +# ── Wallet ─────────────────────────────────────────────────── + +@app.post("/api/wallet/create") +async def create_wallet(body: WalletCreate): + if body.email: + existing = await db.fetch_one( + "SELECT w.token, w.balance_cents, wc.email, wc.name, wc.display_name " + "FROM wallets w LEFT JOIN wallet_contacts wc ON wc.wallet_id = w.id " + "WHERE wc.email = $1", + body.email, + ) + if existing: + return { + "token": existing["token"], + "balance": int(existing["balance_cents"]), + "name": existing["display_name"] or existing["name"] or "", + "email": existing["email"], + "existing": True, + } + + token = secrets.token_hex(32) + + async with db.pool.acquire() as conn: + async with conn.transaction(): + wallet = await conn.fetchrow( + "INSERT INTO wallets (token) VALUES ($1) RETURNING id, token, balance_cents", + token, + ) + if body.email or body.name: + await conn.execute( + "INSERT INTO wallet_contacts (wallet_id, email, name, display_name) VALUES ($1, $2, $3, $4)", + wallet["id"], body.email, body.name, body.name, + ) + + return {"token": token, "balance": 0, "name": body.name or "", "email": body.email} + + +@app.get("/api/wallet") +async def get_wallet_info(authorization: Optional[str] = Header(None)): + wallet = await get_wallet(authorization) + if not wallet: + raise HTTPException(401, "not authenticated") + + contact = await db.fetch_one("SELECT * FROM wallet_contacts WHERE wallet_id = $1", wallet["id"]) + c = dict(contact) if contact else {} + + return { + "balance": int(wallet["balance_cents"]), + "totalFunded": int(wallet["total_funded_cents"]), + "totalTipped": int(wallet["total_tipped_cents"]), + "name": c.get("display_name") or c.get("name") or "", + "email": c.get("email"), + "handle": c.get("handle"), + "did": c.get("did"), + "anonymous": c.get("anonymous", False), + } + + +@app.post("/api/wallet/contact") +async def update_wallet_contact(body: WalletContact, authorization: Optional[str] = Header(None)): + wallet = await get_wallet(authorization) + if not wallet: + raise HTTPException(401, "not authenticated") + + await db.execute( + "INSERT INTO wallet_contacts (wallet_id, email, name, phone, handle, display_name, anonymous) " + "VALUES ($1, $2, $3, $4, $5, $6, $7) " + "ON CONFLICT (wallet_id) DO UPDATE SET " + "email = COALESCE(NULLIF($2, ''), wallet_contacts.email), " + "name = COALESCE(NULLIF($3, ''), wallet_contacts.name), " + "phone = COALESCE(NULLIF($4, ''), wallet_contacts.phone), " + "handle = COALESCE(NULLIF($5, ''), wallet_contacts.handle), " + "display_name = COALESCE(NULLIF($6, ''), wallet_contacts.display_name), " + "anonymous = COALESCE($7, wallet_contacts.anonymous)", + wallet["id"], body.email, body.name, body.phone, body.handle, body.display_name, body.anonymous, + ) + return {"success": True} + + +@app.get("/api/wallet/history") +async def wallet_history(authorization: Optional[str] = Header(None)): + wallet = await get_wallet(authorization) + if not wallet: + raise HTTPException(401, "not authenticated") + + rows = await db.fetch_all( + "SELECT id, type, amount_cents, balance_after_cents, description, created_at " + "FROM wallet_transactions WHERE wallet_id = $1 ORDER BY created_at DESC LIMIT 100", + wallet["id"], + ) + return [ + { + "id": str(r["id"]), + "type": r["type"], + "amount_cents": int(r["amount_cents"]), + "balance_after_cents": int(r["balance_after_cents"]), + "description": r["description"], + "created_at": r["created_at"].isoformat(), + } + for r in rows + ] + + +@app.post("/api/wallet/recover") +async def recover_wallet(body: RecoverRequest): + row = await db.fetch_one( + "SELECT w.token, w.balance_cents, wc.name, wc.display_name, wc.email " + "FROM wallets w JOIN wallet_contacts wc ON wc.wallet_id = w.id WHERE wc.email = $1", + body.email, + ) + if not row: + raise HTTPException(404, "no wallet found for this email") + return { + "token": row["token"], + "balance": int(row["balance_cents"]), + "name": row["display_name"] or row["name"], + "email": row["email"], + } + + +@app.get("/api/auth/status") +async def auth_status(authorization: Optional[str] = Header(None)): + wallet = await get_wallet(authorization) + if not wallet: + return {"authenticated": False} + + contact = await db.fetch_one( + "SELECT name, display_name, handle, did FROM wallet_contacts WHERE wallet_id = $1", + wallet["id"], + ) + c = dict(contact) if contact else {} + return { + "authenticated": True, + "balance": int(wallet["balance_cents"]), + "name": c.get("display_name") or c.get("name") or "", + "handle": c.get("handle"), + "did": c.get("did"), + "hasFunds": wallet["balance_cents"] > 0, + } + + +# ── Fund wallet ────────────────────────────────────────────── + +@app.post("/api/wallet/fund/stripe") +async def fund_stripe(body: FundRequest, authorization: Optional[str] = Header(None)): + wallet = await get_wallet(authorization) + if not wallet: + raise HTTPException(401, "not authenticated") + if body.amount <= 0: + raise HTTPException(400, "amount must be positive") + + amount_cents = round(body.amount * 100) + + if settings.is_demo or not settings.stripe_enabled: + return await _demo_fund(wallet, amount_cents, "stripe") + + session = stripe_lib.checkout.Session.create( + mode="payment", + line_items=[{ + "price_data": { + "currency": "usd", + "product_data": {"name": f"SimpleTip wallet — add ${body.amount}"}, + "unit_amount": amount_cents, + }, + "quantity": 1, + }], + metadata={"wallet_id": str(wallet["id"]), "type": "fund"}, + success_url=f"{settings.node_url}/fund-success.html?amount={body.amount}", + cancel_url=f"{settings.node_url}/fund.html", + ) + + await db.execute( + "INSERT INTO funding (wallet_id, amount_cents, method, payment_provider_id, status) " + "VALUES ($1, $2, 'stripe', $3, 'pending')", + wallet["id"], amount_cents, session.id, + ) + + return {"checkoutUrl": session.url, "sessionId": session.id} + + +@app.post("/api/wallet/fund") +async def fund_generic(body: FundRequest, authorization: Optional[str] = Header(None)): + wallet = await get_wallet(authorization) + if not wallet: + raise HTTPException(401, "not authenticated") + if body.amount <= 0: + raise HTTPException(400, "amount must be positive") + + amount_cents = round(body.amount * 100) + + if settings.is_demo: + return await _demo_fund(wallet, amount_cents, body.method or "demo") + + row = await db.fetch_one( + "INSERT INTO funding (wallet_id, amount_cents, method, status) " + "VALUES ($1, $2, $3, 'pending_confirmation') RETURNING id", + wallet["id"], amount_cents, body.method or "manual", + ) + return { + "fundId": str(row["id"]), + "status": "pending_confirmation", + "message": f"Send ${body.amount} via {body.method}. We'll credit your wallet once confirmed.", + } + + +async def _demo_fund(wallet, amount_cents: int, method: str): + async with db.pool.acquire() as conn: + async with conn.transaction(): + await conn.execute( + "UPDATE wallets SET balance_cents = balance_cents + $1, total_funded_cents = total_funded_cents + $1 WHERE id = $2", + amount_cents, wallet["id"], + ) + fund = await conn.fetchrow( + "INSERT INTO funding (wallet_id, amount_cents, method, status, completed_at) " + "VALUES ($1, $2, $3, 'completed', now()) RETURNING id", + wallet["id"], amount_cents, method + "-demo", + ) + new_balance = await conn.fetchval("SELECT balance_cents FROM wallets WHERE id = $1", wallet["id"]) + await conn.execute( + "INSERT INTO wallet_transactions (wallet_id, type, amount_cents, balance_after_cents, reference_id, reference_type, description) " + "VALUES ($1, 'topup', $2, $3, $4, 'funding', $5)", + wallet["id"], amount_cents, new_balance, fund["id"], f"Demo {method} top-up", + ) + + return {"success": True, "balance": int(new_balance), "demo": True} + + +# ── Tipping ────────────────────────────────────────────────── + +@app.post("/api/tip") +async def tip(body: TipRequest, authorization: Optional[str] = Header(None)): + wallet = await get_wallet(authorization) + if not wallet: + raise HTTPException(401, "not authenticated") + + amount_cents = round(body.amount * 100) + + if wallet["balance_cents"] < amount_cents: + raise HTTPException(402, f"insufficient_funds: balance={wallet['balance_cents']}") + + total_pct = sum(r.pct for r in body.receivers) + if total_pct != 100: + raise HTTPException(400, f"split percentages must sum to 100, got {total_pct}") + + uri = canonicalize_uri(body.page_url) + + async with db.pool.acquire() as conn: + async with conn.transaction(): + article = await conn.fetchrow("SELECT id FROM articles WHERE uri = $1", uri) + article_id = article["id"] if article else None + + tip_row = await conn.fetchrow( + "INSERT INTO tips (wallet_id, article_id, page_url, amount_cents, comment, source, status) " + "VALUES ($1, $2, $3, $4, $5, 'wallet', 'completed') RETURNING id", + wallet["id"], article_id, body.page_url, amount_cents, body.comment, + ) + tip_id = tip_row["id"] + slug_list = " + ".join(r.slug for r in body.receivers) + + for split in body.receivers: + split_cents = round(amount_cents * split.pct / 100) + receiver = await conn.fetchrow("SELECT id, wallet_id FROM receivers WHERE slug = $1", split.slug) + if not receiver: + raise HTTPException(400, f"receiver not found: {split.slug}") + await conn.execute( + "INSERT INTO tip_splits (tip_id, receiver_id, amount_cents, role) VALUES ($1, $2, $3, $4)", + tip_id, receiver["id"], split_cents, split.role, + ) + await conn.execute( + "UPDATE receivers SET total_received_cents = total_received_cents + $1 WHERE id = $2", + split_cents, receiver["id"], + ) + # Credit receiver's wallet (double-entry) + if receiver["wallet_id"]: + await conn.execute( + "UPDATE wallets SET balance_cents = balance_cents + $1 WHERE id = $2", + split_cents, receiver["wallet_id"], + ) + rcv_balance = await conn.fetchval( + "SELECT balance_cents FROM wallets WHERE id = $1", receiver["wallet_id"], + ) + await conn.execute( + "INSERT INTO wallet_transactions (wallet_id, type, amount_cents, balance_after_cents, reference_id, reference_type, description) " + "VALUES ($1, 'tip_received', $2, $3, $4, 'tip', $5)", + receiver["wallet_id"], split_cents, rcv_balance, tip_id, f"Tip received for {split.slug}", + ) + + # Debit donor wallet + await conn.execute( + "UPDATE wallets SET balance_cents = balance_cents - $1, total_tipped_cents = total_tipped_cents + $1 WHERE id = $2", + amount_cents, wallet["id"], + ) + new_balance = await conn.fetchval("SELECT balance_cents FROM wallets WHERE id = $1", wallet["id"]) + + await conn.execute( + "INSERT INTO wallet_transactions (wallet_id, type, amount_cents, balance_after_cents, reference_id, reference_type, description) " + "VALUES ($1, 'tip', $2, $3, $4, 'tip', $5)", + wallet["id"], -amount_cents, new_balance, tip_id, f"Tip to {slug_list}", + ) + + if article_id: + await conn.execute( + "INSERT INTO article_events (article_id, event_type, wallet_id) VALUES ($1, 'tip_completed', $2)", + article_id, wallet["id"], + ) + + total_tipped = await db.fetch_val("SELECT total_tipped_cents FROM wallets WHERE id = $1", wallet["id"]) + + if settings.atproto_enabled: + asyncio.create_task(_publish_tip_safe(tip_id)) + + return {"success": True, "tipId": str(tip_id), "balance": int(new_balance), "totalTipped": int(total_tipped)} + + +@app.post("/api/tip/checkout") +async def tip_checkout(body: TipRequest, authorization: Optional[str] = Header(None)): + """Anonymous tip via Stripe checkout (no wallet needed).""" + amount_cents = round(body.amount * 100) + receiver_names = " + ".join(r.slug for r in body.receivers) + uri = canonicalize_uri(body.page_url) + + article = await db.fetch_one("SELECT id FROM articles WHERE uri = $1", uri) + article_id = article["id"] if article else None + + tip_row = await db.fetch_one( + "INSERT INTO tips (article_id, page_url, amount_cents, comment, source, status) " + "VALUES ($1, $2, $3, $4, 'stripe_direct', 'pending') RETURNING id", + article_id, body.page_url, amount_cents, body.comment, + ) + tip_id = tip_row["id"] + + for split in body.receivers: + split_cents = round(amount_cents * split.pct / 100) + receiver = await db.fetch_one("SELECT id FROM receivers WHERE slug = $1", split.slug) + if receiver: + await db.execute( + "INSERT INTO tip_splits (tip_id, receiver_id, amount_cents, role) VALUES ($1, $2, $3, $4)", + tip_id, receiver["id"], split_cents, split.role, + ) + + if settings.stripe_enabled: + session = stripe_lib.checkout.Session.create( + mode="payment", + line_items=[{ + "price_data": { + "currency": "usd", + "product_data": {"name": f"Tip for {receiver_names}"}, + "unit_amount": amount_cents, + }, + "quantity": 1, + }], + metadata={"tip_id": str(tip_id), "type": "tip"}, + success_url=f"{settings.node_url}/tip-success.html?amount={body.amount}", + cancel_url=body.page_url, + ) + await db.execute("UPDATE tips SET payment_provider_id = $1 WHERE id = $2", session.id, tip_id) + return {"checkoutUrl": session.url, "sessionId": session.id, "tipId": str(tip_id)} + + # Demo mode + return { + "checkoutUrl": f"{settings.node_url}/checkout.html?tip={tip_id}&amount={body.amount}", + "tipId": str(tip_id), + } + + +@app.post("/api/tip/confirm") +async def confirm_tip(body: TipConfirm): + tip = await db.fetch_one("SELECT * FROM tips WHERE id = $1 AND status = 'pending'", body.tip_id) + if not tip: + raise HTTPException(404, "tip not found or already confirmed") + + async with db.pool.acquire() as conn: + async with conn.transaction(): + await conn.execute("UPDATE tips SET status = 'completed' WHERE id = $1", body.tip_id) + splits = await conn.fetch("SELECT * FROM tip_splits WHERE tip_id = $1", body.tip_id) + for s in splits: + await conn.execute( + "UPDATE receivers SET total_received_cents = total_received_cents + $1 WHERE id = $2", + s["amount_cents"], s["receiver_id"], + ) + + if settings.atproto_enabled: + asyncio.create_task(_publish_tip_safe(body.tip_id)) + + return {"success": True} + + +@app.get("/api/wallet/tips") +async def wallet_tips(authorization: Optional[str] = Header(None)): + wallet = await get_wallet(authorization) + if not wallet: + raise HTTPException(401, "not authenticated") + + rows = await db.fetch_all( + "SELECT t.id, t.amount_cents, t.comment, t.page_url, t.source, t.created_at, " + "json_agg(json_build_object('slug', r.slug, 'name', r.name, 'amount_cents', ts.amount_cents, 'role', ts.role)) AS receivers " + "FROM tips t " + "LEFT JOIN tip_splits ts ON ts.tip_id = t.id " + "LEFT JOIN receivers r ON r.id = ts.receiver_id " + "WHERE t.wallet_id = $1 AND t.status = 'completed' " + "GROUP BY t.id ORDER BY t.created_at DESC LIMIT 50", + wallet["id"], + ) + return [dict(r) for r in rows] + + +# ── Pledges ────────────────────────────────────────────────── + +@app.post("/api/pledge") +async def create_pledge(body: PledgeRequest, authorization: Optional[str] = Header(None)): + wallet = await get_wallet(authorization) + if not wallet: + raise HTTPException(401, "not authenticated") + + amount_cents = round(body.amount * 100) + uri = canonicalize_uri(body.page_url) if body.page_url else None + + article_id = None + if uri: + article = await db.fetch_one("SELECT id FROM articles WHERE uri = $1", uri) + article_id = article["id"] if article else None + + async with db.pool.acquire() as conn: + async with conn.transaction(): + pledge = await conn.fetchrow( + "INSERT INTO pledges (wallet_id, article_id, page_url, amount_cents, comment) " + "VALUES ($1, $2, $3, $4, $5) RETURNING id", + wallet["id"], article_id, body.page_url, amount_cents, body.comment, + ) + for split in body.receivers: + split_cents = round(amount_cents * split.pct / 100) + receiver = await conn.fetchrow("SELECT id FROM receivers WHERE slug = $1", split.slug) + if not receiver: + raise HTTPException(400, f"receiver not found: {split.slug}") + await conn.execute( + "INSERT INTO pledge_splits (pledge_id, receiver_id, amount_cents, role) VALUES ($1, $2, $3, $4)", + pledge["id"], receiver["id"], split_cents, split.role, + ) + if article_id: + await conn.execute( + "INSERT INTO article_events (article_id, event_type, wallet_id) VALUES ($1, 'pledge', $2)", + article_id, wallet["id"], + ) + + pending = await db.fetch_one( + "SELECT count(*) AS n, COALESCE(sum(amount_cents), 0) AS total " + "FROM pledges WHERE wallet_id = $1 AND status = 'pending'", + wallet["id"], + ) + return { + "success": True, + "pledgeId": str(pledge["id"]), + "pendingCount": int(pending["n"]), + "pendingTotal": int(pending["total"]), + } + + +@app.get("/api/pledges") +async def list_pledges(authorization: Optional[str] = Header(None)): + wallet = await get_wallet(authorization) + if not wallet: + raise HTTPException(401, "not authenticated") + + rows = await db.fetch_all( + "SELECT p.id, p.amount_cents, p.comment, p.page_url, p.created_at, " + "json_agg(json_build_object('slug', r.slug, 'name', r.name, 'amount_cents', ps.amount_cents, 'role', ps.role)) AS receivers " + "FROM pledges p " + "LEFT JOIN pledge_splits ps ON ps.pledge_id = p.id " + "LEFT JOIN receivers r ON r.id = ps.receiver_id " + "WHERE p.wallet_id = $1 AND p.status = 'pending' " + "GROUP BY p.id ORDER BY p.created_at DESC", + wallet["id"], + ) + total = sum(int(r["amount_cents"]) for r in rows) + return {"pledges": [dict(r) for r in rows], "total": total} + + +@app.post("/api/pledges/fulfill") +async def fulfill_pledges(authorization: Optional[str] = Header(None)): + wallet = await get_wallet(authorization) + if not wallet: + raise HTTPException(401, "not authenticated") + + pending = await db.fetch_all( + "SELECT * FROM pledges WHERE wallet_id = $1 AND status = 'pending' ORDER BY created_at ASC", + wallet["id"], + ) + if not pending: + return {"fulfilled": 0} + + total_needed = sum(int(p["amount_cents"]) for p in pending) + if wallet["balance_cents"] < total_needed: + raise HTTPException(402, f"insufficient_funds: balance={wallet['balance_cents']}, needed={total_needed}") + + fulfilled = 0 + balance = int(wallet["balance_cents"]) + + async with db.pool.acquire() as conn: + async with conn.transaction(): + for pledge in pending: + pledge_amount = int(pledge["amount_cents"]) + if balance < pledge_amount: + break + + tip = await conn.fetchrow( + "INSERT INTO tips (wallet_id, article_id, page_url, amount_cents, comment, source, status) " + "VALUES ($1, $2, $3, $4, $5, 'pledge', 'completed') RETURNING id", + wallet["id"], pledge["article_id"], pledge["page_url"] or "", pledge_amount, pledge["comment"], + ) + + splits = await conn.fetch("SELECT * FROM pledge_splits WHERE pledge_id = $1", pledge["id"]) + for s in splits: + await conn.execute( + "INSERT INTO tip_splits (tip_id, receiver_id, amount_cents, role) VALUES ($1, $2, $3, $4)", + tip["id"], s["receiver_id"], s["amount_cents"], s["role"], + ) + await conn.execute( + "UPDATE receivers SET total_received_cents = total_received_cents + $1 WHERE id = $2", + int(s["amount_cents"]), s["receiver_id"], + ) + # Credit receiver wallet (double-entry) + rcv_wallet_id = await conn.fetchval( + "SELECT wallet_id FROM receivers WHERE id = $1", s["receiver_id"], + ) + if rcv_wallet_id: + await conn.execute( + "UPDATE wallets SET balance_cents = balance_cents + $1 WHERE id = $2", + int(s["amount_cents"]), rcv_wallet_id, + ) + rcv_bal = await conn.fetchval("SELECT balance_cents FROM wallets WHERE id = $1", rcv_wallet_id) + await conn.execute( + "INSERT INTO wallet_transactions (wallet_id, type, amount_cents, balance_after_cents, reference_id, reference_type, description) " + "VALUES ($1, 'tip_received', $2, $3, $4, 'tip', 'Tip received (pledge fulfilled)')", + rcv_wallet_id, int(s["amount_cents"]), rcv_bal, tip["id"], + ) + + # Debit donor wallet + await conn.execute( + "UPDATE wallets SET balance_cents = balance_cents - $1, total_tipped_cents = total_tipped_cents + $1 WHERE id = $2", + pledge_amount, wallet["id"], + ) + balance -= pledge_amount + + await conn.execute( + "INSERT INTO wallet_transactions (wallet_id, type, amount_cents, balance_after_cents, reference_id, reference_type, description) " + "VALUES ($1, 'tip', $2, $3, $4, 'tip', 'Fulfilled pledge')", + wallet["id"], -pledge_amount, balance, tip["id"], + ) + + await conn.execute( + "UPDATE pledges SET status = 'fulfilled', fulfilled_tip_id = $1 WHERE id = $2", + tip["id"], pledge["id"], + ) + fulfilled += 1 + + if settings.atproto_enabled: + asyncio.create_task(_publish_tip_safe(tip["id"])) + + return {"fulfilled": fulfilled, "balance": balance} + + +# ── Stripe webhook ─────────────────────────────────────────── + +@app.post("/api/webhook/stripe") +async def stripe_webhook(request: Request): + if not settings.stripe_enabled: + raise HTTPException(400, "Stripe not configured") + + body = await request.body() + + if settings.stripe_webhook_secret: + sig = request.headers.get("stripe-signature", "") + try: + event = stripe_lib.Webhook.construct_event(body, sig, settings.stripe_webhook_secret) + except Exception as e: + raise HTTPException(400, f"Webhook error: {e}") + else: + import json + event = json.loads(body) + + if event.get("type") == "checkout.session.completed": + session = event["data"]["object"] + meta = session.get("metadata", {}) + + if meta.get("type") == "fund": + fund = await db.fetch_one( + "SELECT * FROM funding WHERE payment_provider_id = $1 AND status = 'pending'", + session["id"], + ) + if fund: + async with db.pool.acquire() as conn: + async with conn.transaction(): + await conn.execute("UPDATE funding SET status = 'completed', completed_at = now() WHERE id = $1", fund["id"]) + await conn.execute( + "UPDATE wallets SET balance_cents = balance_cents + $1, total_funded_cents = total_funded_cents + $1 WHERE id = $2", + fund["amount_cents"], fund["wallet_id"], + ) + new_bal = await conn.fetchval("SELECT balance_cents FROM wallets WHERE id = $1", fund["wallet_id"]) + await conn.execute( + "INSERT INTO wallet_transactions (wallet_id, type, amount_cents, balance_after_cents, reference_id, reference_type, description) " + "VALUES ($1, 'topup', $2, $3, $4, 'funding', 'Stripe payment')", + fund["wallet_id"], fund["amount_cents"], new_bal, fund["id"], + ) + + elif meta.get("type") == "tip": + tip_id = meta.get("tip_id") + tip = await db.fetch_one("SELECT * FROM tips WHERE id = $1 AND status = 'pending'", tip_id) + if tip: + async with db.pool.acquire() as conn: + async with conn.transaction(): + await conn.execute("UPDATE tips SET status = 'completed' WHERE id = $1", tip_id) + splits = await conn.fetch("SELECT * FROM tip_splits WHERE tip_id = $1", tip_id) + for s in splits: + await conn.execute( + "UPDATE receivers SET total_received_cents = total_received_cents + $1 WHERE id = $2", + s["amount_cents"], s["receiver_id"], + ) + # Credit receiver wallet (double-entry) + rcv_wallet_id = await conn.fetchval( + "SELECT wallet_id FROM receivers WHERE id = $1", s["receiver_id"], + ) + if rcv_wallet_id: + await conn.execute( + "UPDATE wallets SET balance_cents = balance_cents + $1 WHERE id = $2", + s["amount_cents"], rcv_wallet_id, + ) + rcv_bal = await conn.fetchval("SELECT balance_cents FROM wallets WHERE id = $1", rcv_wallet_id) + await conn.execute( + "INSERT INTO wallet_transactions (wallet_id, type, amount_cents, balance_after_cents, reference_id, reference_type, description) " + "VALUES ($1, 'tip_received', $2, $3, $4, 'tip', 'Tip received (Stripe direct)')", + rcv_wallet_id, int(s["amount_cents"]), rcv_bal, tip_id, + ) + if settings.atproto_enabled: + asyncio.create_task(_publish_tip_safe(tip_id)) + + return {"received": True} + + +# ── Receiver dashboard ─────────────────────────────────────── + +@app.get("/api/receiver/{slug}/dashboard") +async def receiver_dashboard(slug: str): + r = await db.fetch_one("SELECT * FROM receivers WHERE slug = $1", slug) + if not r: + raise HTTPException(404, "receiver not found") + + tips = await db.fetch_all( + "SELECT t.amount_cents, ts.amount_cents AS split_amount_cents, t.comment, t.source, t.page_url, t.created_at, " + "wc.display_name AS tipper_name, wc.handle AS tipper_handle, wc.anonymous AS tipper_anonymous " + "FROM tip_splits ts " + "JOIN tips t ON t.id = ts.tip_id " + "LEFT JOIN wallet_contacts wc ON wc.wallet_id = t.wallet_id " + "WHERE ts.receiver_id = $1 AND t.status = 'completed' " + "ORDER BY t.created_at DESC LIMIT 100", + r["id"], + ) + + pledges = await db.fetch_all( + "SELECT p.amount_cents, ps.amount_cents AS split_amount_cents, p.comment, p.created_at, " + "wc.display_name AS pledger_name, wc.handle AS pledger_handle " + "FROM pledge_splits ps " + "JOIN pledges p ON p.id = ps.pledge_id " + "LEFT JOIN wallet_contacts wc ON wc.wallet_id = p.wallet_id " + "WHERE ps.receiver_id = $1 AND p.status = 'pending' " + "ORDER BY p.created_at DESC", + r["id"], + ) + + stats = await db.fetch_one( + "SELECT count(*) AS tip_count, COALESCE(sum(ts.amount_cents), 0) AS total_received " + "FROM tip_splits ts JOIN tips t ON t.id = ts.tip_id " + "WHERE ts.receiver_id = $1 AND t.status = 'completed'", + r["id"], + ) + + payouts = await db.fetch_all( + "SELECT id, amount_cents, status, initiated_at, completed_at " + "FROM payouts WHERE receiver_id = $1 ORDER BY initiated_at DESC LIMIT 20", + r["id"], + ) + + total_paid_out = sum(int(p["amount_cents"]) for p in payouts if p["status"] in ("completed", "processing", "pending")) + + # Available balance from wallet (source of truth) + wallet_balance = 0 + if r["wallet_id"]: + wb = await db.fetch_val("SELECT balance_cents FROM wallets WHERE id = $1", r["wallet_id"]) + wallet_balance = int(wb) if wb else 0 + + # Annual payout cap tracking + paid_this_year = await db.fetch_val( + "SELECT COALESCE(sum(amount_cents), 0) FROM payouts " + "WHERE receiver_id = $1 AND status IN ('completed', 'processing', 'pending') " + "AND initiated_at >= date_trunc('year', now())", + r["id"], + ) + remaining_annual_cap = ANNUAL_PAYOUT_CAP_CENTS - int(paid_this_year) + + return { + "receiver": {"slug": r["slug"], "name": r["name"], "type": r["type"], "createdAt": r["created_at"].isoformat()}, + "tips": [ + { + **{k: (v.isoformat() if hasattr(v, "isoformat") else v) for k, v in dict(t).items()}, + "tipper_name": "Anonymous" if t["tipper_anonymous"] else (t["tipper_name"] or "Anonymous"), + "tipper_handle": None if t.get("tipper_anonymous") else t["tipper_handle"], + } + for t in tips + ], + "pledges": [{k: (v.isoformat() if hasattr(v, "isoformat") else v) for k, v in dict(p).items()} for p in pledges], + "payouts": [{k: (v.isoformat() if hasattr(v, "isoformat") else v) for k, v in dict(p).items()} for p in payouts], + "stats": { + "tipCount": int(stats["tip_count"]), + "totalReceived": int(stats["total_received"]), + "totalPaidOut": total_paid_out, + "availableBalance": wallet_balance, + "annualPayoutRemaining": max(0, remaining_annual_cap), + "annualPayoutCap": ANNUAL_PAYOUT_CAP_CENTS, + }, + } + + +# ── Receiver payout request ────────────────────────────────── + +MINIMUM_PAYOUT_CENTS = 1000 # $10.00 +ANNUAL_PAYOUT_CAP_CENTS = 60000 # $600.00 per receiver per calendar year (below 1099 threshold) + +@app.post("/api/receiver/{slug}/request-payout") +async def request_payout(slug: str, authorization: Optional[str] = Header(None)): + r = await db.fetch_one("SELECT * FROM receivers WHERE slug = $1", slug) + if not r: + raise HTTPException(404, "receiver not found") + + # Must have a wallet linked + if not r["wallet_id"]: + raise HTTPException(400, "no wallet linked to this receiver — re-register to link one") + + # Auth check: only the receiver's own wallet can request payout + wallet = await get_wallet(authorization) + if not wallet or wallet["id"] != r["wallet_id"]: + raise HTTPException(403, "only the receiver can request a payout") + + method = await db.fetch_one( + "SELECT * FROM receiver_payout_methods WHERE receiver_id = $1 AND is_preferred = true", + r["id"], + ) + if not method: + raise HTTPException(400, "no payout method configured") + + # Check annual payout cap ($600/year per receiver for non-Stripe-Connect) + # TODO: skip this check for Stripe Connect receivers once implemented + paid_this_year = await db.fetch_val( + "SELECT COALESCE(sum(amount_cents), 0) FROM payouts " + "WHERE receiver_id = $1 AND status IN ('completed', 'processing', 'pending') " + "AND initiated_at >= date_trunc('year', now())", + r["id"], + ) + remaining_cap = ANNUAL_PAYOUT_CAP_CENTS - int(paid_this_year) + + if remaining_cap <= 0: + raise HTTPException(400, + "You've reached the $600 annual payout limit. " + "Link a Stripe Connect account to continue receiving payouts." + ) + + # Available = wallet balance, capped at remaining annual limit + available = min(int(wallet["balance_cents"]), remaining_cap) + if available < MINIMUM_PAYOUT_CENTS: + raise HTTPException(400, f"minimum payout is ${MINIMUM_PAYOUT_CENTS / 100:.2f} — current balance: ${available / 100:.2f}") + + async with db.pool.acquire() as conn: + async with conn.transaction(): + payout_row = await conn.fetchrow( + "INSERT INTO payouts (receiver_id, payout_method_id, amount_cents, status) " + "VALUES ($1, $2, $3, 'pending') RETURNING id", + r["id"], method["id"], available, + ) + # Debit receiver wallet + await conn.execute( + "UPDATE wallets SET balance_cents = balance_cents - $1 WHERE id = $2", + available, r["wallet_id"], + ) + new_balance = await conn.fetchval( + "SELECT balance_cents FROM wallets WHERE id = $1", r["wallet_id"], + ) + # Ledger entry + await conn.execute( + "INSERT INTO wallet_transactions (wallet_id, type, amount_cents, balance_after_cents, reference_id, reference_type, description) " + "VALUES ($1, 'payout', $2, $3, $4, 'payout', $5)", + r["wallet_id"], -available, new_balance, payout_row["id"], f"Payout requested: ${available / 100:.2f}", + ) + + return {"payoutId": str(payout_row["id"]), "amount": available, "status": "pending"} + + +# ── Article stats ──────────────────────────────────────────── + +@app.get("/api/article/stats") +async def article_stats(uri: str): + article = await db.fetch_one("SELECT * FROM articles WHERE uri = $1", canonicalize_uri(uri)) + if not article: + raise HTTPException(404, "article not found") + + impressions = await db.fetch_val( + "SELECT count(*) FROM article_events WHERE article_id = $1 AND event_type = 'impression'", + article["id"], + ) + tip_stats = await db.fetch_one( + "SELECT count(*) AS n, COALESCE(sum(amount_cents), 0) AS total " + "FROM tips WHERE article_id = $1 AND status = 'completed'", + article["id"], + ) + receivers = await db.fetch_all( + "SELECT r.slug, r.name, ar.role, ar.default_split_pct " + "FROM article_receivers ar JOIN receivers r ON r.id = ar.receiver_id WHERE ar.article_id = $1", + article["id"], + ) + + return { + "uri": article["uri"], + "title": article["title"], + "siteName": article["site_name"], + "impressions": impressions, + "tips": int(tip_stats["n"]), + "totalTipped": int(tip_stats["total"]), + "receivers": [dict(r) for r in receivers], + } + + +# ── Global stats ───────────────────────────────────────────── + +@app.get("/api/stats") +async def global_stats(): + total = await db.fetch_one( + "SELECT count(*) AS n, COALESCE(sum(amount_cents), 0) AS total FROM tips WHERE status = 'completed'" + ) + top = await db.fetch_all( + "SELECT slug, name, total_received_cents, " + "(SELECT count(*) FROM tip_splits WHERE receiver_id = receivers.id) AS tip_count " + "FROM receivers WHERE total_received_cents > 0 ORDER BY total_received_cents DESC LIMIT 10" + ) + return { + "totalTips": int(total["n"]), + "totalAmount": int(total["total"]), + "topReceivers": [dict(r) for r in top], + } + + +# ── Admin ──────────────────────────────────────────────────── + +@app.post("/api/admin/confirm-funding") +async def admin_confirm_funding(body: AdminConfirmFunding): + fund = await db.fetch_one( + "SELECT * FROM funding WHERE id = $1 AND status = 'pending_confirmation'", + body.fund_id, + ) + if not fund: + raise HTTPException(404, "funding not found") + + async with db.pool.acquire() as conn: + async with conn.transaction(): + await conn.execute("UPDATE funding SET status = 'completed', completed_at = now() WHERE id = $1", fund["id"]) + await conn.execute( + "UPDATE wallets SET balance_cents = balance_cents + $1, total_funded_cents = total_funded_cents + $1 WHERE id = $2", + fund["amount_cents"], fund["wallet_id"], + ) + new_bal = await conn.fetchval("SELECT balance_cents FROM wallets WHERE id = $1", fund["wallet_id"]) + await conn.execute( + "INSERT INTO wallet_transactions (wallet_id, type, amount_cents, balance_after_cents, reference_id, reference_type, description) " + "VALUES ($1, 'topup', $2, $3, $4, 'funding', 'Manual payment confirmed')", + fund["wallet_id"], fund["amount_cents"], new_bal, fund["id"], + ) + return {"success": True} + + +@app.post("/api/admin/confirm-payout") +async def admin_confirm_payout(body: AdminConfirmPayout): + await db.execute( + "UPDATE payouts SET status = 'completed', completed_at = now(), provider_reference = $1 " + "WHERE id = $2 AND status IN ('pending', 'processing')", + body.provider_reference, body.payout_id, + ) + return {"success": True} + + +@app.post("/api/admin/atproto/publish-batch") +async def admin_atproto_publish_batch(since_hours: int = Query(default=24)): + if not settings.atproto_enabled: + raise HTTPException(400, "ATProto not configured — set ATPROTO_HANDLE and ATPROTO_APP_PASSWORD") + + from atproto_publisher import publish_batch + async with db.pool.acquire() as conn: + result = await publish_batch(conn, since_hours) + return result + + +# ── ATProto OAuth ──────────────────────────────────────────── + +@app.get("/client-metadata.json") +async def client_metadata(): + """OAuth client metadata — required by ATProto OAuth.""" + return atproto_oauth.get_client_metadata() + + +class OAuthStartRequest(BaseModel): + handle: str + scope: str = "atproto" # Start with read, upgrade to transition:authfull for writes + + +@app.post("/api/oauth/start") +async def oauth_start(body: OAuthStartRequest): + """Start ATProto OAuth flow. Returns URL to redirect user to.""" + try: + result = await atproto_oauth.start_auth(body.handle, body.scope) + return result + except Exception as e: + raise HTTPException(400, str(e)) + + +@app.get("/api/oauth/callback") +async def oauth_callback(code: str = Query(...), state: str = Query(...)): + """OAuth callback — exchange code for tokens.""" + try: + result = await atproto_oauth.handle_callback(code, state) + # Redirect to login success page with session info + from fastapi.responses import RedirectResponse + return RedirectResponse( + url=f"{settings.node_url}/login-success.html?session={result['session_id']}&did={result['did']}&handle={result['handle']}&scope={result['scope']}" + ) + except Exception as e: + raise HTTPException(400, str(e)) + + +class OAuthUpgradeRequest(BaseModel): + session_id: str + + +@app.post("/api/oauth/upgrade") +async def oauth_upgrade(body: OAuthUpgradeRequest): + """Upgrade OAuth scope from atproto to transition:authfull for writing claims.""" + try: + result = await atproto_oauth.upgrade_scope(body.session_id) + return result + except Exception as e: + raise HTTPException(400, str(e)) + + +@app.get("/api/oauth/session") +async def oauth_session(session_id: str = Query(...)): + """Check OAuth session status.""" + session = atproto_oauth.get_session(session_id) + if not session: + return {"authenticated": False} + return { + "authenticated": True, + "did": session["did"], + "handle": session["handle"], + "scope": session["scope"], + "can_write": session["scope"] == "transition:authfull", + } + + +# ── Static files (public/ directory) ───────────────────────── + +import os +public_dir = os.path.join(os.path.dirname(__file__), "..", "public") +if os.path.isdir(public_dir): + app.mount("/", StaticFiles(directory=public_dir, html=True), name="static") diff --git a/backend/atproto_oauth.py b/backend/atproto_oauth.py new file mode 100644 index 0000000..fda6cd7 --- /dev/null +++ b/backend/atproto_oauth.py @@ -0,0 +1,232 @@ +""" +ATProto OAuth — progressive scope upgrade for SimpleTip. + +Flow: +1. User logs in with `atproto` scope (basic read) +2. When they want to publish claims, we upgrade to `transition:authfull` +3. CRITICAL: never use `transition:generic` — only `transition:authfull` + +Uses ATProto OAuth 2.0 with PKCE and DPoP. +""" + +import hashlib +import hmac +import logging +import secrets +import time +from base64 import urlsafe_b64encode +from urllib.parse import urlencode + +import httpx + +from config import settings + +log = logging.getLogger("simpletip.oauth") + +# Scope constants +SCOPE_READ = "atproto" +SCOPE_WRITE = "transition:authfull" # CRITICAL: never use transition:generic + +# In-memory session store (use Redis/DB in production) +_oauth_states: dict[str, dict] = {} +_oauth_sessions: dict[str, dict] = {} + + +def _generate_pkce(): + """Generate PKCE code verifier and challenge.""" + verifier = secrets.token_urlsafe(64) + digest = hashlib.sha256(verifier.encode()).digest() + challenge = urlsafe_b64encode(digest).rstrip(b"=").decode() + return verifier, challenge + + +async def resolve_auth_server(handle: str) -> dict: + """Resolve a handle to its ATProto authorization server metadata.""" + # First resolve the handle to a DID + async with httpx.AsyncClient() as client: + # Try resolving via the handle's PDS + resp = await client.get( + f"https://bsky.social/xrpc/com.atproto.identity.resolveHandle", + params={"handle": handle}, + ) + resp.raise_for_status() + did = resp.json()["did"] + + # Get the PDS service endpoint from the DID document + resp = await client.get(f"https://plc.directory/{did}") + resp.raise_for_status() + did_doc = resp.json() + + # Find PDS service endpoint + pds_url = None + for service in did_doc.get("service", []): + if service.get("type") == "AtprotoPersonalDataServer": + pds_url = service["serviceEndpoint"] + break + + if not pds_url: + raise ValueError(f"No PDS found for {handle}") + + # Get authorization server metadata from PDS + resp = await client.get(f"{pds_url}/.well-known/oauth-authorization-server") + resp.raise_for_status() + auth_meta = resp.json() + + return { + "did": did, + "pds_url": pds_url, + "authorization_endpoint": auth_meta["authorization_endpoint"], + "token_endpoint": auth_meta["token_endpoint"], + "pushed_authorization_request_endpoint": auth_meta.get("pushed_authorization_request_endpoint"), + } + + +def get_client_metadata() -> dict: + """Return OAuth client metadata for SimpleTip.""" + base_url = settings.node_url + return { + "client_id": f"{base_url}/client-metadata.json", + "client_name": settings.node_name, + "client_uri": base_url, + "redirect_uris": [f"{base_url}/api/oauth/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "scope": f"{SCOPE_READ} {SCOPE_WRITE}", + "token_endpoint_auth_method": "none", + "application_type": "web", + "dpop_bound_access_tokens": True, + } + + +async def start_auth(handle: str, scope: str = SCOPE_READ) -> dict: + """Start OAuth flow. Returns authorization URL to redirect user to. + + Args: + handle: User's ATProto handle (e.g., alice.bsky.social) + scope: OAuth scope — 'atproto' for read, 'transition:authfull' for write + """ + if scope not in (SCOPE_READ, SCOPE_WRITE): + raise ValueError(f"Invalid scope: {scope}. Use '{SCOPE_READ}' or '{SCOPE_WRITE}'") + + auth_server = await resolve_auth_server(handle) + verifier, challenge = _generate_pkce() + state = secrets.token_urlsafe(32) + + # Store state for callback verification + _oauth_states[state] = { + "handle": handle, + "did": auth_server["did"], + "verifier": verifier, + "scope": scope, + "token_endpoint": auth_server["token_endpoint"], + "created_at": time.monotonic(), + } + + client_id = f"{settings.node_url}/client-metadata.json" + redirect_uri = f"{settings.node_url}/api/oauth/callback" + + params = { + "response_type": "code", + "client_id": client_id, + "redirect_uri": redirect_uri, + "scope": scope, + "state": state, + "code_challenge": challenge, + "code_challenge_method": "S256", + "login_hint": handle, + } + + # Use PAR if available + par_endpoint = auth_server.get("pushed_authorization_request_endpoint") + if par_endpoint: + async with httpx.AsyncClient() as client: + resp = await client.post(par_endpoint, data=params) + if resp.status_code == 201: + par_data = resp.json() + auth_url = f"{auth_server['authorization_endpoint']}?request_uri={par_data['request_uri']}&client_id={client_id}" + return {"authorization_url": auth_url, "state": state} + + # Fallback to standard authorization URL + auth_url = f"{auth_server['authorization_endpoint']}?{urlencode(params)}" + return {"authorization_url": auth_url, "state": state} + + +async def handle_callback(code: str, state: str) -> dict: + """Exchange authorization code for tokens.""" + if state not in _oauth_states: + raise ValueError("Invalid or expired state") + + oauth_state = _oauth_states.pop(state) + + # Check expiry (10 minutes) + if time.monotonic() - oauth_state["created_at"] > 600: + raise ValueError("OAuth state expired") + + client_id = f"{settings.node_url}/client-metadata.json" + redirect_uri = f"{settings.node_url}/api/oauth/callback" + + async with httpx.AsyncClient() as client: + resp = await client.post( + oauth_state["token_endpoint"], + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": client_id, + "code_verifier": oauth_state["verifier"], + }, + ) + resp.raise_for_status() + tokens = resp.json() + + session_id = secrets.token_urlsafe(32) + _oauth_sessions[session_id] = { + "did": oauth_state["did"], + "handle": oauth_state["handle"], + "scope": oauth_state["scope"], + "access_token": tokens["access_token"], + "refresh_token": tokens.get("refresh_token"), + "token_endpoint": oauth_state["token_endpoint"], + "expires_at": time.monotonic() + tokens.get("expires_in", 3600), + } + + return { + "session_id": session_id, + "did": oauth_state["did"], + "handle": oauth_state["handle"], + "scope": oauth_state["scope"], + } + + +async def upgrade_scope(session_id: str) -> dict: + """Upgrade an existing session from atproto to transition:authfull. + + This is the progressive scope upgrade — user starts with read, + then upgrades to write when they want to publish claims. + """ + session = _oauth_sessions.get(session_id) + if not session: + raise ValueError("Invalid session") + + if session["scope"] == SCOPE_WRITE: + return {"already_upgraded": True, "scope": SCOPE_WRITE} + + # Start a new auth flow with write scope + result = await start_auth(session["handle"], scope=SCOPE_WRITE) + return { + "needs_reauth": True, + "authorization_url": result["authorization_url"], + "state": result["state"], + } + + +def get_session(session_id: str) -> dict | None: + """Get an active OAuth session.""" + session = _oauth_sessions.get(session_id) + if not session: + return None + if time.monotonic() > session["expires_at"]: + # TODO: refresh token + del _oauth_sessions[session_id] + return None + return session diff --git a/backend/atproto_publisher.py b/backend/atproto_publisher.py new file mode 100644 index 0000000..33807d8 --- /dev/null +++ b/backend/atproto_publisher.py @@ -0,0 +1,238 @@ +""" +ATProto publisher — posts completed tips as com.linkedclaims.claim records. + +Uses the @cooperation/claim-atproto SDK (via Node.js subprocess) for claim +building, validation, and publishing. The SDK ensures claims conform to the +DIF Labs LinkedClaims specification. + +Claim mapping (tip → LinkedClaims): + subject = tipper DID (who made the tip — the person asserting value) + claimType = "tip" + object = receiver name (whose work is being valued) + statement = human-readable: "Tipped $50.00 — great article!" + source.uri = content URL (what was tipped for) + source.howKnown = "FIRST_HAND" (tipper directly valued this) + confidence = 1.0 (tip is a concrete action, not speculation) + effectiveDate = when the tip was created +""" + +import asyncio +import json +import logging +import os +from datetime import datetime, timezone + +from config import settings + +log = logging.getLogger("simpletip.atproto") + +# Path to the Node.js publish script that uses claim-atproto SDK +_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +_PUBLISH_SCRIPT = os.path.join(_SCRIPT_DIR, "publish-claim.mjs") + + +async def _run_sdk(tip_data: dict) -> dict: + """Call the claim-atproto SDK via Node.js subprocess. + + Returns {"uri": "at://...", "cid": "..."} on success. + Raises RuntimeError on failure. + """ + env = { + **os.environ, + "ATPROTO_HANDLE": settings.atproto_handle, + "ATPROTO_APP_PASSWORD": settings.atproto_app_password, + "ATPROTO_SERVICE": settings.atproto_service, + } + + proc = await asyncio.create_subprocess_exec( + "node", _PUBLISH_SCRIPT, json.dumps(tip_data), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=env, + ) + stdout, stderr = await proc.communicate() + + if proc.returncode != 0: + err_msg = stderr.decode().strip() if stderr else "unknown error" + try: + err_data = json.loads(err_msg) + err_msg = err_data.get("error", err_msg) + except json.JSONDecodeError: + pass + raise RuntimeError(f"claim-atproto SDK error: {err_msg}") + + result = json.loads(stdout.decode().strip()) + return result + + +# ── Publish a single tip ───────────────────────────────────── + +async def publish_tip(tip_id, conn) -> str | None: + """Build and publish a tip as a LinkedClaim via the SDK. Returns AT-URI or None on failure.""" + tip = await conn.fetchrow( + "SELECT t.id, t.wallet_id, t.amount_cents, t.comment, t.page_url, t.created_at, t.atproto_uri " + "FROM tips t WHERE t.id = $1 AND t.status = 'completed'", + tip_id, + ) + if not tip: + log.warning("publish_tip: tip %s not found or not completed", tip_id) + return None + if tip["atproto_uri"]: + log.debug("publish_tip: tip %s already published: %s", tip_id, tip["atproto_uri"]) + return tip["atproto_uri"] + + # Get tipper info from wallet_contacts + tipper_info = await conn.fetchrow( + "SELECT wc.did, wc.handle, wc.name, wc.display_name, wc.anonymous " + "FROM wallet_contacts wc " + "WHERE wc.wallet_id = $1", + tip["wallet_id"], + ) + + splits = await conn.fetch( + "SELECT r.slug, r.name, ts.amount_cents, ts.role " + "FROM tip_splits ts JOIN receivers r ON r.id = ts.receiver_id " + "WHERE ts.tip_id = $1", + tip_id, + ) + if not splits: + log.warning("publish_tip: tip %s has no splits", tip_id) + return None + + # ── Build claim data for the SDK ───────────────────────── + + # Subject = the tipper (who is making the claim with their money) + tipper_did = "" + if tipper_info and not tipper_info["anonymous"]: + tipper_did = tipper_info["did"] or tipper_info["handle"] or "" + + # Object = the receiver (whose work is being valued) + receiver_names = " + ".join(s["name"] or s["slug"] for s in splits) + + # Statement = human-readable description + amount_str = f"${tip['amount_cents'] / 100:.2f}" + statement = f"Tipped {amount_str} to {receiver_names}" + if tip["comment"]: + statement += f" — {tip['comment']}" + + # Timestamps + created_at = tip["created_at"] + if isinstance(created_at, datetime): + if created_at.tzinfo is None: + created_at = created_at.replace(tzinfo=timezone.utc) + iso_ts = created_at.isoformat(timespec="milliseconds") + else: + iso_ts = str(created_at) + + # Data passed to the SDK script + tip_data = { + "subject": tipper_did, + "object": receiver_names, + "statement": statement, + "effectiveDate": iso_ts, + "createdAt": iso_ts, + } + if tip["page_url"]: + tip_data["sourceUri"] = tip["page_url"] + + # Publish via claim-atproto SDK + result = await _run_sdk(tip_data) + + at_uri = result.get("uri", "") + if at_uri: + await conn.execute("UPDATE tips SET atproto_uri = $1 WHERE id = $2", at_uri, tip_id) + log.info("Published tip %s → %s (via claim-atproto SDK)", tip_id, at_uri) + + return at_uri + + +# ── Batch publish ──────────────────────────────────────────── + +async def publish_batch(conn, since_hours: int = 24) -> dict: + """Publish all unpublished completed tips from the last N hours. Returns summary.""" + rows = await conn.fetch( + "SELECT id FROM tips " + "WHERE status = 'completed' AND atproto_uri IS NULL " + "AND created_at > now() - make_interval(hours => $1) " + "ORDER BY created_at ASC", + since_hours, + ) + + published = 0 + failed = 0 + errors = [] + + for row in rows: + try: + uri = await publish_tip(row["id"], conn) + if uri: + published += 1 + else: + failed += 1 + errors.append(f"tip {row['id']}: returned None") + except Exception as e: + failed += 1 + errors.append(f"tip {row['id']}: {e}") + log.exception("Batch publish failed for tip %s", row["id"]) + + return {"published": published, "failed": failed, "errors": errors} + + +# ── Batch summary claim ────────────────────────────────────── + +async def publish_summary(conn, since_hours: int = 24) -> str | None: + """Publish a summary claim for high-volume periods. + + Instead of one claim per tip, this creates a single claim summarizing + all tips in the time window. Used when tip volume is high. + """ + rows = await conn.fetch( + "SELECT t.id, t.amount_cents, t.page_url, t.created_at, " + " array_agg(DISTINCT r.name) AS receiver_names " + "FROM tips t " + "JOIN tip_splits ts ON ts.tip_id = t.id " + "JOIN receivers r ON r.id = ts.receiver_id " + "WHERE t.status = 'completed' AND t.atproto_uri IS NULL " + "AND t.created_at > now() - make_interval(hours => $1) " + "GROUP BY t.id ORDER BY t.created_at ASC", + since_hours, + ) + + if not rows: + return None + + total_cents = sum(r["amount_cents"] for r in rows) + total_str = f"${total_cents / 100:.2f}" + tip_count = len(rows) + + # Collect unique receivers + all_receivers = set() + for r in rows: + for name in r["receiver_names"]: + if name: + all_receivers.add(name) + receivers_str = ", ".join(sorted(all_receivers)) + + now_ts = datetime.now(timezone.utc).isoformat(timespec="milliseconds") + + tip_data = { + "subject": settings.node_url, + "object": receivers_str, + "statement": f"Summary: {tip_count} tips totaling {total_str} to {receivers_str}", + "effectiveDate": now_ts, + "createdAt": now_ts, + "sourceUri": settings.node_url, + } + + result = await _run_sdk(tip_data) + + at_uri = result.get("uri", "") + if at_uri: + tip_ids = [r["id"] for r in rows] + await conn.execute( + "UPDATE tips SET atproto_uri = $1 WHERE id = ANY($2::uuid[])", + at_uri, tip_ids, + ) + log.info("Published summary claim for %d tips → %s (via SDK)", tip_count, at_uri) + + return at_uri diff --git a/backend/config.js b/backend/config.js deleted file mode 100644 index db8f8c4..0000000 --- a/backend/config.js +++ /dev/null @@ -1,83 +0,0 @@ -/** - * SimpleTip Node Configuration - * - * Enable payment methods by providing credentials. - * Only configured methods show up in the UI. - * Uses env vars — set them in systemd unit or .env file. - */ - -module.exports = { - // Node identity - nodeName: process.env.SIMPLETIP_NODE_NAME || 'SimpleTip by LinkedTrust', - nodeUrl: process.env.SIMPLETIP_NODE_URL || 'https://demos.linkedtrust.us/simpletip', - - // Processing fee (percentage, 0 for nonprofit nodes) - feePercent: parseFloat(process.env.SIMPLETIP_FEE_PCT || '0'), - - // Payment methods — each has an `enabled` flag derived from whether creds exist. - // The /api/methods endpoint returns only enabled ones to the frontend. - payments: { - stripe: { - enabled: !!process.env.STRIPE_SECRET_KEY, - secretKey: process.env.STRIPE_SECRET_KEY || '', - publishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '', - webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '', - label: 'Card / Apple Pay / Google Pay', - icon: 'card', - // Stripe handles: Visa, Mastercard, Amex, Apple Pay, Google Pay, ACH, SEPA, etc. - }, - - paypal: { - enabled: !!process.env.PAYPAL_CLIENT_ID, - clientId: process.env.PAYPAL_CLIENT_ID || '', - clientSecret: process.env.PAYPAL_CLIENT_SECRET || '', - mode: process.env.PAYPAL_MODE || 'sandbox', // 'sandbox' or 'live' - label: 'PayPal / Venmo', - icon: 'paypal', - }, - - zelle: { - enabled: !!process.env.ZELLE_ADDRESS, - address: process.env.ZELLE_ADDRESS || '', // email or phone - label: 'Zelle', - icon: 'zelle', - // Manual: reader sends to this address, admin confirms receipt - }, - - mpesa: { - enabled: !!process.env.CHIMONEY_API_KEY, - chimoneyKey: process.env.CHIMONEY_API_KEY || '', - label: 'M-Pesa', - icon: 'mpesa', - }, - - cashapp: { - enabled: !!process.env.CASHAPP_TAG, - tag: process.env.CASHAPP_TAG || '', - label: 'Cash App', - icon: 'cashapp', - }, - - crypto: { - enabled: !!process.env.CRYPTO_ADDRESS, - address: process.env.CRYPTO_ADDRESS || '', - network: process.env.CRYPTO_NETWORK || 'USDT (Ethereum)', - label: 'Crypto (USDT)', - icon: 'crypto', - // Reader sends to this address, backend watches for tx or admin confirms - }, - - // ILP / Open Payments — if the node wants to support Interledger - ilp: { - enabled: !!process.env.ILP_WALLET_ADDRESS, - walletAddress: process.env.ILP_WALLET_ADDRESS || '', - label: 'Interledger', - icon: 'ilp', - }, - }, - - // Demo mode — simulated payments (no real money) - demoMode: process.env.SIMPLETIP_DEMO === '1' - || (!process.env.STRIPE_SECRET_KEY && !process.env.PAYPAL_CLIENT_ID - && !process.env.ZELLE_ADDRESS && !process.env.CRYPTO_ADDRESS), -}; diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..32c6d6b --- /dev/null +++ b/backend/config.py @@ -0,0 +1,54 @@ +""" +SimpleTip configuration — all from env vars. + +Copy .env.example to .env for local dev, or set in systemd unit for production. +""" + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # Database + database_url: str = "postgres://simpletip:password@localhost:5432/simpletip" + + # Encryption key for payout method details (64-char hex = 32 bytes) + encryption_key: str = "" + + # Node identity + node_name: str = "SimpleTip by LinkedTrust" + node_url: str = "https://demos.linkedtrust.us/simpletip" + + # Stripe + stripe_secret_key: str = "" + stripe_publishable_key: str = "" + stripe_webhook_secret: str = "" + + # ATProto publishing (optional — disabled if handle is empty) + atproto_handle: str = "" + atproto_app_password: str = "" + atproto_service: str = "https://bsky.social" + + # Port + port: int = 8046 + + # Demo mode — forced on when no payment keys are configured + demo_mode: bool = False + + @property + def is_demo(self) -> bool: + return self.demo_mode or not self.stripe_secret_key + + @property + def stripe_enabled(self) -> bool: + return bool(self.stripe_secret_key) + + @property + def atproto_enabled(self) -> bool: + return bool(self.atproto_handle and self.atproto_app_password) + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + +settings = Settings() diff --git a/backend/db.py b/backend/db.py new file mode 100644 index 0000000..959c7a2 --- /dev/null +++ b/backend/db.py @@ -0,0 +1,51 @@ +""" +Async Postgres connection pool for SimpleTip. +""" + +import asyncpg +from config import settings + +pool: asyncpg.Pool | None = None + + +def _fix_dsn(url: str) -> str: + """Convert postgres:// to postgresql:// for asyncpg.""" + if url.startswith("postgres://"): + return "postgresql://" + url[len("postgres://"):] + return url + + +async def init_pool(): + global pool + pool = await asyncpg.create_pool( + _fix_dsn(settings.database_url), + min_size=2, + max_size=20, + ) + + +async def close_pool(): + global pool + if pool: + await pool.close() + pool = None + + +async def fetch_one(query: str, *args): + async with pool.acquire() as conn: + return await conn.fetchrow(query, *args) + + +async def fetch_all(query: str, *args): + async with pool.acquire() as conn: + return await conn.fetch(query, *args) + + +async def execute(query: str, *args): + async with pool.acquire() as conn: + return await conn.execute(query, *args) + + +async def fetch_val(query: str, *args): + async with pool.acquire() as conn: + return await conn.fetchval(query, *args) diff --git a/backend/encryption.py b/backend/encryption.py new file mode 100644 index 0000000..37aec28 --- /dev/null +++ b/backend/encryption.py @@ -0,0 +1,66 @@ +""" +AES-256-GCM encryption for sensitive payout method details. + +ENCRYPTION_KEY env var: 64-char hex string (32 bytes). +Generate with: python3 -c "import secrets; print(secrets.token_hex(32))" +""" + +import json +import os +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +NONCE_LENGTH = 12 + + +def _get_key() -> bytes: + hex_key = os.environ.get("ENCRYPTION_KEY", "") + if len(hex_key) != 64: + raise ValueError( + "ENCRYPTION_KEY must be 64 hex chars (32 bytes). " + "Generate: python3 -c \"import secrets; print(secrets.token_hex(32))\"" + ) + return bytes.fromhex(hex_key) + + +def encrypt(data: dict) -> bytes: + """Encrypt a dict to bytes (nonce + ciphertext+tag). Store as Postgres BYTEA.""" + key = _get_key() + aesgcm = AESGCM(key) + nonce = os.urandom(NONCE_LENGTH) + plaintext = json.dumps(data).encode("utf-8") + ct = aesgcm.encrypt(nonce, plaintext, None) + return nonce + ct + + +def decrypt(blob: bytes) -> dict: + """Decrypt bytes back to dict.""" + key = _get_key() + aesgcm = AESGCM(key) + nonce = blob[:NONCE_LENGTH] + ct = blob[NONCE_LENGTH:] + plaintext = aesgcm.decrypt(nonce, ct, None) + return json.loads(plaintext.decode("utf-8")) + + +def mask_detail(key: str, value: str) -> str: + """Mask a single payout detail for safe display.""" + if not value: + return "***" + s = str(value) + if "email" in key: + parts = s.split("@") + if len(parts) == 2: + return parts[0][0] + "***@" + parts[1] + if "address" in key and s.startswith("0x"): + return s[:6] + "..." + s[-4:] + if "phone" in key or "routing" in key or "account_number" in key: + return "***" + s[-4:] + if len(s) > 6: + return s[:3] + "***" + s[-2:] + return "***" + + +def masked_details(blob: bytes) -> dict: + """Decrypt and return masked version (safe for API responses).""" + data = decrypt(blob) + return {k: mask_detail(k, v) for k, v in data.items()} diff --git a/backend/package-lock.json b/backend/package-lock.json index dac6214..3600dec 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,130 +9,46 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@atproto-labs/simple-store-memory": "^0.1.4", - "@atproto/api": "^0.19.4", - "@atproto/oauth-client-node": "^0.3.17", - "better-sqlite3": "^12.8.0", - "cors": "^2.8.6", - "crypto-random-string": "^5.0.0", - "express": "^5.2.1", - "stripe": "^20.4.1" + "@atproto/api": "^0.19.5", + "@cooperation/claim-atproto": "file:../../claim-atproto" } }, - "node_modules/@atproto-labs/did-resolver": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.6.tgz", - "integrity": "sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg==", - "license": "MIT", - "dependencies": { - "@atproto-labs/fetch": "0.2.3", - "@atproto-labs/pipe": "0.1.1", - "@atproto-labs/simple-store": "0.3.0", - "@atproto-labs/simple-store-memory": "0.1.4", - "@atproto/did": "0.3.0", - "zod": "^3.23.8" - } - }, - "node_modules/@atproto-labs/fetch": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.2.3.tgz", - "integrity": "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==", - "license": "MIT", - "dependencies": { - "@atproto-labs/pipe": "0.1.1" - } - }, - "node_modules/@atproto-labs/fetch-node": { + "../../claim-atproto": { + "name": "@cooperation/claim-atproto", "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@atproto-labs/fetch-node/-/fetch-node-0.2.0.tgz", - "integrity": "sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q==", "license": "MIT", "dependencies": { - "@atproto-labs/fetch": "0.2.3", - "@atproto-labs/pipe": "0.1.1", - "ipaddr.js": "^2.1.0", - "undici": "^6.14.1" + "@atproto/api": "^0.13.0", + "@atproto/lexicon": "^0.4.1", + "multiformats": "^13.3.1" }, - "engines": { - "node": ">=18.7.0" - } - }, - "node_modules/@atproto-labs/fetch-node/node_modules/ipaddr.js": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", - "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/@atproto-labs/handle-resolver": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.3.6.tgz", - "integrity": "sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA==", - "license": "MIT", - "dependencies": { - "@atproto-labs/simple-store": "0.3.0", - "@atproto-labs/simple-store-memory": "0.1.4", - "@atproto/did": "0.3.0", - "zod": "^3.23.8" - } - }, - "node_modules/@atproto-labs/handle-resolver-node": { - "version": "0.1.25", - "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver-node/-/handle-resolver-node-0.1.25.tgz", - "integrity": "sha512-NY9WYM2VLd3IuMGRkkmvGBg8xqVEaK/fitv1vD8SMXqFTekdpjOLCCyv7EFtqVHouzmDcL83VOvWRfHVa8V9Yw==", - "license": "MIT", - "dependencies": { - "@atproto-labs/fetch-node": "0.2.0", - "@atproto-labs/handle-resolver": "0.3.6", - "@atproto/did": "0.3.0" + "devDependencies": { + "@types/node": "^22.10.5", + "@typescript-eslint/eslint-plugin": "^8.19.1", + "@typescript-eslint/parser": "^8.19.1", + "@vitest/ui": "^2.1.8", + "eslint": "^9.17.0", + "prettier": "^3.4.2", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^2.1.8" }, "engines": { - "node": ">=18.7.0" - } - }, - "node_modules/@atproto-labs/identity-resolver": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.3.6.tgz", - "integrity": "sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg==", - "license": "MIT", - "dependencies": { - "@atproto-labs/did-resolver": "0.2.6", - "@atproto-labs/handle-resolver": "0.3.6" - } - }, - "node_modules/@atproto-labs/pipe": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.1.1.tgz", - "integrity": "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==", - "license": "MIT" - }, - "node_modules/@atproto-labs/simple-store": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.3.0.tgz", - "integrity": "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==", - "license": "MIT" - }, - "node_modules/@atproto-labs/simple-store-memory": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.4.tgz", - "integrity": "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==", - "license": "MIT", - "dependencies": { - "@atproto-labs/simple-store": "0.3.0", - "lru-cache": "^10.2.0" + "node": ">=18.0.0" + }, + "peerDependencies": { + "@atproto/api": ">=0.12.0" } }, "node_modules/@atproto/api": { - "version": "0.19.4", - "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.19.4.tgz", - "integrity": "sha512-fYNM62vdXxer0h8a9Jzl4/ag9uFIe0nTO+LkC6KTlx1yUDigrAoQMMbllIiCWj62GhUMxAkHabk/BZjjVAfKng==", + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.19.5.tgz", + "integrity": "sha512-u6R5TecYJDO8l8QFN09AMuJASYnUkJ4HhYE5hg4/dha/z14a+OAil2/dli/208uM5AHPFLtlnB8kIK9XU5GgQQ==", "license": "MIT", "dependencies": { - "@atproto/common-web": "^0.4.18", + "@atproto/common-web": "^0.4.19", "@atproto/lexicon": "^0.6.2", - "@atproto/syntax": "^0.5.1", + "@atproto/syntax": "^0.5.2", "@atproto/xrpc": "^0.7.7", "await-lock": "^2.2.2", "multiformats": "^9.9.0", @@ -152,46 +68,6 @@ "zod": "^3.23.8" } }, - "node_modules/@atproto/did": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.3.0.tgz", - "integrity": "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA==", - "license": "MIT", - "dependencies": { - "zod": "^3.23.8" - } - }, - "node_modules/@atproto/jwk": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.6.0.tgz", - "integrity": "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==", - "license": "MIT", - "dependencies": { - "multiformats": "^9.9.0", - "zod": "^3.23.8" - } - }, - "node_modules/@atproto/jwk-jose": { - "version": "0.1.11", - "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.11.tgz", - "integrity": "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==", - "license": "MIT", - "dependencies": { - "@atproto/jwk": "0.6.0", - "jose": "^5.2.0" - } - }, - "node_modules/@atproto/jwk-webcrypto": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.2.0.tgz", - "integrity": "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg==", - "license": "MIT", - "dependencies": { - "@atproto/jwk": "0.6.0", - "@atproto/jwk-jose": "0.1.11", - "zod": "^3.23.8" - } - }, "node_modules/@atproto/lex-data": { "version": "0.0.14", "resolved": "https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.14.tgz", @@ -227,62 +103,10 @@ "zod": "^3.23.8" } }, - "node_modules/@atproto/oauth-client": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.6.0.tgz", - "integrity": "sha512-F7ZTKzFptXgyihMkd7QTdRSkrh4XqrS+qTw+V81k5Q6Bh3MB1L3ypvfSJ6v7SSUJa6XxoZYJTCahHC1e+ndE6Q==", - "license": "MIT", - "dependencies": { - "@atproto-labs/did-resolver": "^0.2.6", - "@atproto-labs/fetch": "^0.2.3", - "@atproto-labs/handle-resolver": "^0.3.6", - "@atproto-labs/identity-resolver": "^0.3.6", - "@atproto-labs/simple-store": "^0.3.0", - "@atproto-labs/simple-store-memory": "^0.1.4", - "@atproto/did": "^0.3.0", - "@atproto/jwk": "^0.6.0", - "@atproto/oauth-types": "^0.6.3", - "@atproto/xrpc": "^0.7.7", - "core-js": "^3", - "multiformats": "^9.9.0", - "zod": "^3.23.8" - } - }, - "node_modules/@atproto/oauth-client-node": { - "version": "0.3.17", - "resolved": "https://registry.npmjs.org/@atproto/oauth-client-node/-/oauth-client-node-0.3.17.tgz", - "integrity": "sha512-67LNuKAlC35Exe7CB5S0QCAnEqr6fKV9Nvp64jAHFof1N+Vc9Ltt1K9oekE5Ctf7dvpGByrHRF0noUw9l9sWLA==", - "license": "MIT", - "dependencies": { - "@atproto-labs/did-resolver": "^0.2.6", - "@atproto-labs/handle-resolver-node": "^0.1.25", - "@atproto-labs/simple-store": "^0.3.0", - "@atproto/did": "^0.3.0", - "@atproto/jwk": "^0.6.0", - "@atproto/jwk-jose": "^0.1.11", - "@atproto/jwk-webcrypto": "^0.2.0", - "@atproto/oauth-client": "^0.6.0", - "@atproto/oauth-types": "^0.6.3" - }, - "engines": { - "node": ">=18.7.0" - } - }, - "node_modules/@atproto/oauth-types": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.6.3.tgz", - "integrity": "sha512-jdKuoPknJuh/WjI+mYk7agSbx9mNVMbS6Dr3k1z2YMY2oRiCQjxYBuo4MLKATbxj05nMQaZRWlHRUazoAu5Cng==", - "license": "MIT", - "dependencies": { - "@atproto/did": "^0.3.0", - "@atproto/jwk": "^0.6.0", - "zod": "^3.23.8" - } - }, "node_modules/@atproto/syntax": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.5.1.tgz", - "integrity": "sha512-J8DJjgKgACIyCTbpfvoTnf7+ofTx1kxTGO7KAftkC+jczaMdQhKdgIBAg2DaYy+80cvYGTHy5q/HI9qMAwGbWw==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.5.2.tgz", + "integrity": "sha512-W41szOnkppoHr0iCUrzL8gy3OD6qmDyp1UvUgmTx2oFQfgbudpz51T/gznesiCcqiUT5obfHdx4PJ+WdlEOE7Q==", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -298,18 +122,9 @@ "zod": "^3.23.8" } }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } + "node_modules/@cooperation/claim-atproto": { + "resolved": "../../claim-atproto", + "link": true }, "node_modules/await-lock": { "version": "2.2.2", @@ -317,1382 +132,48 @@ "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==", "license": "MIT" }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/better-sqlite3": { - "version": "12.8.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", - "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" - } - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/body-parser": { + "node_modules/iso-datestring-validator": { "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/core-js": { - "version": "3.49.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", - "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", - "hasInstallScript": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/crypto-random-string": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-5.0.0.tgz", - "integrity": "sha512-KWjTXWwxFd6a94m5CdRGW/t82Tr8DoBc9dNnPCAbFI1EBweN6v1tv8y4Y1m7ndkp/nkIBRxUxAzpaBnR2k3bcQ==", - "license": "MIT", - "dependencies": { - "type-fest": "^2.12.2" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", "license": "MIT" }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } + "node_modules/multiformats": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", + "license": "(Apache-2.0 AND MIT)" }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "bin": { + "tlds": "bin.js" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "node_modules/uint8arrays": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", "license": "MIT", "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" + "multiformats": "^9.4.2" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "node_modules/unicode-segmenter": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==", "license": "MIT" }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/iso-datestring-validator": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", - "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==", - "license": "MIT" - }, - "node_modules/jose": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", - "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/multiformats": { - "version": "9.9.0", - "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", - "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==", - "license": "(Apache-2.0 AND MIT)" - }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-abi": { - "version": "3.89.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", - "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stripe": { - "version": "20.4.1", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.1.tgz", - "integrity": "sha512-axCguHItc8Sxt0HC6aSkdVRPffjYPV7EQqZRb2GkIa8FzWDycE7nHJM19C6xAIynH1Qp1/BHiopSi96jGBxT0w==", - "license": "MIT", - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "@types/node": ">=16" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tlds": { - "version": "1.261.0", - "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", - "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", - "license": "MIT", - "bin": { - "tlds": "bin.js" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/uint8arrays": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", - "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", - "license": "MIT", - "dependencies": { - "multiformats": "^9.4.2" - } - }, - "node_modules/undici": { - "version": "6.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", - "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, - "node_modules/unicode-segmenter": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", - "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==", - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/backend/package.json b/backend/package.json index 7d5e97b..ff4bae1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,7 @@ { "name": "backend", "version": "1.0.0", + "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" @@ -8,15 +9,8 @@ "keywords": [], "author": "", "license": "ISC", - "description": "", "dependencies": { - "@atproto-labs/simple-store-memory": "^0.1.4", - "@atproto/api": "^0.19.4", - "@atproto/oauth-client-node": "^0.3.17", - "better-sqlite3": "^12.8.0", - "cors": "^2.8.6", - "crypto-random-string": "^5.0.0", - "express": "^5.2.1", - "stripe": "^20.4.1" + "@atproto/api": "^0.19.5", + "@cooperation/claim-atproto": "file:../../claim-atproto" } } diff --git a/backend/publish-claim.mjs b/backend/publish-claim.mjs new file mode 100644 index 0000000..e15b20d --- /dev/null +++ b/backend/publish-claim.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +/** + * Publish a tip claim using the @cooperation/claim-atproto SDK. + * + * Called from Python backend via subprocess: + * node publish-claim.mjs '{"tip": {...}}' + * + * Reads ATProto credentials from env: ATPROTO_HANDLE, ATPROTO_APP_PASSWORD, ATPROTO_SERVICE + * Outputs JSON: {"uri": "at://...", "cid": "..."} on success + * Exits non-zero with error message on failure. + */ + +import { AtpAgent } from "@atproto/api"; +import { + ClaimClient, + createClaim, + createSource, +} from "@cooperation/claim-atproto"; + +const ATPROTO_HANDLE = process.env.ATPROTO_HANDLE; +const ATPROTO_APP_PASSWORD = process.env.ATPROTO_APP_PASSWORD; +const ATPROTO_SERVICE = process.env.ATPROTO_SERVICE || "https://bsky.social"; + +if (!ATPROTO_HANDLE || !ATPROTO_APP_PASSWORD) { + console.error( + JSON.stringify({ error: "ATPROTO_HANDLE and ATPROTO_APP_PASSWORD required" }) + ); + process.exit(1); +} + +// Read tip data from CLI argument +const input = process.argv[2]; +if (!input) { + console.error(JSON.stringify({ error: "Usage: publish-claim.mjs '{...}'" })); + process.exit(1); +} + +let tipData; +try { + tipData = JSON.parse(input); +} catch (e) { + console.error(JSON.stringify({ error: `Invalid JSON: ${e.message}` })); + process.exit(1); +} + +async function main() { + // Authenticate with ATProto + const agent = new AtpAgent({ service: ATPROTO_SERVICE }); + await agent.login({ + identifier: ATPROTO_HANDLE, + password: ATPROTO_APP_PASSWORD, + }); + + const client = new ClaimClient({ agent }); + + // Build claim using the SDK builders + const builder = createClaim() + .subject(tipData.subject) // tipper DID + .type("tip") // claimType + .confidence(1.0); // tip is a concrete action + + if (tipData.object) { + builder.object(tipData.object); // receiver name + } + if (tipData.statement) { + builder.statement(tipData.statement); // human-readable + } + if (tipData.effectiveDate) { + builder.effectiveDate(tipData.effectiveDate); + } + if (tipData.createdAt) { + builder.createdAt(tipData.createdAt); + } + + // Add source (content URL) if provided + if (tipData.sourceUri) { + const sourceBuilder = createSource() + .uri(tipData.sourceUri) + .howKnown("FIRST_HAND"); + builder.withSource(sourceBuilder); + } + + const claim = builder.build(); + + // Publish using the SDK + const published = await client.publish(claim); + + // Output result as JSON + console.log(JSON.stringify({ + uri: published.uri, + cid: published.cid, + })); +} + +main().catch((err) => { + console.error(JSON.stringify({ error: err.message })); + process.exit(1); +}); diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..68c8909 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,25 @@ +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.13.0 +asyncpg==0.31.0 +certifi==2026.2.25 +cffi==2.0.0 +charset-normalizer==3.4.6 +click==8.3.1 +cryptography==46.0.6 +fastapi==0.135.2 +h11==0.16.0 +httpx>=0.27 +idna==3.11 +pycparser==3.0 +pydantic==2.12.5 +pydantic-settings==2.13.1 +pydantic_core==2.41.5 +python-dotenv==1.2.2 +requests==2.33.0 +starlette==1.0.0 +stripe==15.0.0 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +urllib3==2.6.3 +uvicorn==0.42.0 diff --git a/backend/schema.sql b/backend/schema.sql new file mode 100644 index 0000000..992f9fb --- /dev/null +++ b/backend/schema.sql @@ -0,0 +1,251 @@ +-- SimpleTip Database Schema +-- Postgres 15+ +-- Run: psql -h -U simpletip -d simpletip -f schema.sql + +-- Enable UUID generation +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- ============================================================ +-- RECEIVERS (tip recipients — authors, subjects, orgs) +-- ============================================================ +CREATE TABLE receivers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + email TEXT, + type TEXT NOT NULL DEFAULT 'individual', + bio TEXT, + avatar_url TEXT, + wallet_id UUID REFERENCES wallets(id), + payout_threshold_cents BIGINT DEFAULT 1000, + total_received_cents BIGINT DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_receivers_email ON receivers(email); +CREATE INDEX idx_receivers_status ON receivers(status); + +-- ============================================================ +-- RECEIVER PAYOUT METHODS (encrypted details) +-- ============================================================ +CREATE TABLE receiver_payout_methods ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + receiver_id UUID NOT NULL REFERENCES receivers(id) ON DELETE CASCADE, + method_type TEXT NOT NULL, + details_encrypted BYTEA NOT NULL, + is_preferred BOOLEAN NOT NULL DEFAULT false, + verified BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(receiver_id, method_type) +); + +CREATE INDEX idx_payout_methods_receiver ON receiver_payout_methods(receiver_id); + +-- ============================================================ +-- ARTICLES (pages where widget is embedded, auto-created) +-- ============================================================ +CREATE TABLE articles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + uri TEXT UNIQUE NOT NULL, + title TEXT, + site_name TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- ============================================================ +-- ARTICLE <-> RECEIVER links +-- ============================================================ +CREATE TABLE article_receivers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + article_id UUID NOT NULL REFERENCES articles(id) ON DELETE CASCADE, + receiver_id UUID NOT NULL REFERENCES receivers(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'author', + default_split_pct INTEGER NOT NULL DEFAULT 50, + UNIQUE(article_id, receiver_id) +); + +CREATE INDEX idx_article_receivers_article ON article_receivers(article_id); +CREATE INDEX idx_article_receivers_receiver ON article_receivers(receiver_id); + +-- ============================================================ +-- WALLETS (tipper/donor accounts) +-- ============================================================ +CREATE TABLE wallets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token TEXT UNIQUE NOT NULL, + balance_cents BIGINT NOT NULL DEFAULT 0, + total_funded_cents BIGINT NOT NULL DEFAULT 0, + total_tipped_cents BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- ============================================================ +-- WALLET CONTACTS (all optional sender info) +-- ============================================================ +CREATE TABLE wallet_contacts ( + wallet_id UUID PRIMARY KEY REFERENCES wallets(id) ON DELETE CASCADE, + email TEXT, + name TEXT, + phone TEXT, + handle TEXT, + did TEXT, + display_name TEXT, + anonymous BOOLEAN NOT NULL DEFAULT false +); + +CREATE INDEX idx_wallet_contacts_email ON wallet_contacts(email); +CREATE INDEX idx_wallet_contacts_did ON wallet_contacts(did); + +-- ============================================================ +-- WALLET TRANSACTIONS (full ledger — source of truth for balance) +-- ============================================================ +CREATE TABLE wallet_transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + wallet_id UUID NOT NULL REFERENCES wallets(id), + type TEXT NOT NULL, + amount_cents BIGINT NOT NULL, + balance_after_cents BIGINT NOT NULL, + reference_id UUID, + reference_type TEXT, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_wallet_tx_wallet ON wallet_transactions(wallet_id); +CREATE INDEX idx_wallet_tx_created ON wallet_transactions(created_at); +CREATE INDEX idx_wallet_tx_type ON wallet_transactions(type); + +-- ============================================================ +-- FUNDING (money into wallets) +-- ============================================================ +CREATE TABLE funding ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + wallet_id UUID NOT NULL REFERENCES wallets(id), + amount_cents BIGINT NOT NULL, + method TEXT NOT NULL, + payment_provider_id TEXT, + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ +); + +CREATE INDEX idx_funding_wallet ON funding(wallet_id); +CREATE INDEX idx_funding_status ON funding(status); + +-- ============================================================ +-- TIPS (core transaction) +-- ============================================================ +CREATE TABLE tips ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + wallet_id UUID REFERENCES wallets(id), + article_id UUID REFERENCES articles(id), + page_url TEXT NOT NULL, + amount_cents BIGINT NOT NULL, + comment TEXT, + source TEXT NOT NULL DEFAULT 'wallet', + payment_provider_id TEXT, + status TEXT NOT NULL DEFAULT 'completed', + atproto_uri TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_tips_wallet ON tips(wallet_id); +CREATE INDEX idx_tips_article ON tips(article_id); +CREATE INDEX idx_tips_created ON tips(created_at); +CREATE INDEX idx_tips_status ON tips(status); + +-- ============================================================ +-- TIP SPLITS (how each tip divides among receivers) +-- ============================================================ +CREATE TABLE tip_splits ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tip_id UUID NOT NULL REFERENCES tips(id) ON DELETE CASCADE, + receiver_id UUID NOT NULL REFERENCES receivers(id), + amount_cents BIGINT NOT NULL, + role TEXT NOT NULL DEFAULT 'primary' +); + +CREATE INDEX idx_tip_splits_tip ON tip_splits(tip_id); +CREATE INDEX idx_tip_splits_receiver ON tip_splits(receiver_id); + +-- ============================================================ +-- PAYOUTS (money out to receivers) +-- ============================================================ +CREATE TABLE payouts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + receiver_id UUID NOT NULL REFERENCES receivers(id), + payout_method_id UUID NOT NULL REFERENCES receiver_payout_methods(id), + amount_cents BIGINT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + provider_reference TEXT, + initiated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ, + atproto_uri TEXT +); + +CREATE INDEX idx_payouts_receiver ON payouts(receiver_id); +CREATE INDEX idx_payouts_status ON payouts(status); + +-- ============================================================ +-- PLEDGES (intent to tip, fulfilled after funding) +-- ============================================================ +CREATE TABLE pledges ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + wallet_id UUID NOT NULL REFERENCES wallets(id), + article_id UUID REFERENCES articles(id), + page_url TEXT, + amount_cents BIGINT NOT NULL, + comment TEXT, + status TEXT NOT NULL DEFAULT 'pending', + fulfilled_tip_id UUID REFERENCES tips(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_pledges_wallet ON pledges(wallet_id); +CREATE INDEX idx_pledges_status ON pledges(status); + +-- ============================================================ +-- PLEDGE SPLITS +-- ============================================================ +CREATE TABLE pledge_splits ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + pledge_id UUID NOT NULL REFERENCES pledges(id) ON DELETE CASCADE, + receiver_id UUID NOT NULL REFERENCES receivers(id), + amount_cents BIGINT NOT NULL, + role TEXT NOT NULL DEFAULT 'primary' +); + +CREATE INDEX idx_pledge_splits_pledge ON pledge_splits(pledge_id); +CREATE INDEX idx_pledge_splits_receiver ON pledge_splits(receiver_id); + +-- ============================================================ +-- ARTICLE EVENTS (impressions + interaction tracking) +-- ============================================================ +CREATE TABLE article_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + article_id UUID NOT NULL REFERENCES articles(id), + event_type TEXT NOT NULL, + wallet_id UUID REFERENCES wallets(id), + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_article_events_article ON article_events(article_id); +CREATE INDEX idx_article_events_type ON article_events(event_type); +CREATE INDEX idx_article_events_created ON article_events(created_at); + +-- ============================================================ +-- ARTICLE STATS (daily rollup for dashboards) +-- ============================================================ +CREATE TABLE article_stats_daily ( + article_id UUID NOT NULL REFERENCES articles(id), + day DATE NOT NULL, + impressions INTEGER NOT NULL DEFAULT 0, + unique_visitors INTEGER NOT NULL DEFAULT 0, + tips_started INTEGER NOT NULL DEFAULT 0, + tips_completed INTEGER NOT NULL DEFAULT 0, + pledges INTEGER NOT NULL DEFAULT 0, + total_tipped_cents BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY(article_id, day) +); diff --git a/backend/server.js b/backend/server.js deleted file mode 100644 index a8b6523..0000000 --- a/backend/server.js +++ /dev/null @@ -1,897 +0,0 @@ -/** - * SimpleTip Backend - * - * Simple ledger + payment processing for tips. - * Port: 8046 - * - * Payment methods are configured via env vars (see config.js). - * Only configured methods appear in the UI. - */ - -const express = require('express'); -const cors = require('cors'); -const Database = require('better-sqlite3'); -const crypto = require('crypto'); -const path = require('path'); -const config = require('./config'); - -// ATProto OAuth (conditional — needs HTTPS domain) -let oauthClient = null; -let NodeOAuthClient, SimpleStoreMemory; - -const app = express(); -app.use(cors({ origin: true, credentials: true })); - -// Stripe webhook needs raw body — must come before express.json() -app.post('/api/webhook/stripe', express.raw({ type: 'application/json' }), handleStripeWebhook); -app.use(express.json()); - -const PORT = 8046; -const DB_PATH = path.join(__dirname, 'simpletip.db'); - -// ── Stripe (conditional) ──────────────────────────────────── - -let stripe = null; -if (config.payments.stripe.enabled) { - stripe = require('stripe')(config.payments.stripe.secretKey); - console.log('Stripe configured'); -} - -// ── Database ───────────────────────────────────────────────── - -const db = new Database(DB_PATH); -db.pragma('journal_mode = WAL'); -db.pragma('foreign_keys = ON'); - -db.exec(` - CREATE TABLE IF NOT EXISTS authors ( - id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))), - slug TEXT UNIQUE NOT NULL, - name TEXT NOT NULL, - email TEXT UNIQUE NOT NULL, - payout_method TEXT NOT NULL DEFAULT 'paypal', - payout_address TEXT NOT NULL DEFAULT '', - created_at TEXT DEFAULT (datetime('now')), - total_received REAL DEFAULT 0 - ); - - CREATE TABLE IF NOT EXISTS wallets ( - id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))), - email TEXT UNIQUE NOT NULL, - name TEXT DEFAULT '', - token TEXT UNIQUE NOT NULL, - balance REAL DEFAULT 0, - total_funded REAL DEFAULT 0, - total_tipped REAL DEFAULT 0, - created_at TEXT DEFAULT (datetime('now')) - ); - - CREATE TABLE IF NOT EXISTS tips ( - id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))), - wallet_id TEXT, - author_slug TEXT NOT NULL, - subject_slug TEXT, - amount REAL NOT NULL, - author_amount REAL NOT NULL, - subject_amount REAL DEFAULT 0, - source TEXT NOT NULL DEFAULT 'wallet', - payment_session TEXT, - status TEXT DEFAULT 'completed', - created_at TEXT DEFAULT (datetime('now')), - FOREIGN KEY (author_slug) REFERENCES authors(slug) - ); - - CREATE TABLE IF NOT EXISTS funding ( - id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))), - wallet_id TEXT NOT NULL, - amount REAL NOT NULL, - method TEXT NOT NULL DEFAULT 'demo', - payment_session TEXT, - status TEXT DEFAULT 'pending', - created_at TEXT DEFAULT (datetime('now')), - FOREIGN KEY (wallet_id) REFERENCES wallets(id) - ); - - CREATE TABLE IF NOT EXISTS pledges ( - id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))), - wallet_id TEXT NOT NULL, - author_slug TEXT NOT NULL, - subject_slug TEXT, - amount REAL NOT NULL, - author_amount REAL NOT NULL, - subject_amount REAL DEFAULT 0, - status TEXT DEFAULT 'pending', - fulfilled_tip_id TEXT, - created_at TEXT DEFAULT (datetime('now')), - FOREIGN KEY (wallet_id) REFERENCES wallets(id), - FOREIGN KEY (author_slug) REFERENCES authors(slug) - ); - - CREATE INDEX IF NOT EXISTS idx_tips_author ON tips(author_slug); - CREATE INDEX IF NOT EXISTS idx_tips_wallet ON tips(wallet_id); - CREATE INDEX IF NOT EXISTS idx_pledges_wallet ON pledges(wallet_id); - CREATE INDEX IF NOT EXISTS idx_pledges_author ON pledges(author_slug); -`); - -// Add DID and handle columns to wallets if not present -try { db.exec('ALTER TABLE wallets ADD COLUMN did TEXT'); } catch (e) {} -try { db.exec('ALTER TABLE wallets ADD COLUMN handle TEXT'); } catch (e) {} - -// ── Auth middleware ─────────────────────────────────────────── - -function authWallet(req, res, next) { - const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - req.wallet = null; - return next(); - } - const token = authHeader.slice(7); - req.wallet = db.prepare('SELECT * FROM wallets WHERE token = ?').get(token) || null; - next(); -} - -app.use(authWallet); - -// ── Routes ─────────────────────────────────────────────────── - -// Health -app.get('/api/health', (req, res) => { - const authorCount = db.prepare('SELECT count(*) as n FROM authors').get().n; - const tipCount = db.prepare('SELECT count(*) as n FROM tips').get().n; - const walletCount = db.prepare('SELECT count(*) as n FROM wallets').get().n; - res.json({ - status: 'ok', - node: config.nodeName, - demoMode: config.demoMode, - authors: authorCount, - tips: tipCount, - wallets: walletCount, - }); -}); - -// Available payment methods (frontend calls this to know what to show) -app.get('/api/methods', (req, res) => { - const methods = []; - for (const [key, cfg] of Object.entries(config.payments)) { - if (cfg.enabled || config.demoMode) { - const m = { id: key, label: cfg.label, icon: cfg.icon }; - // Include public info for manual methods - if (key === 'zelle' && cfg.address) m.address = cfg.address; - if (key === 'cashapp' && cfg.tag) m.tag = cfg.tag; - if (key === 'crypto' && cfg.address) { m.address = cfg.address; m.network = cfg.network; } - if (key === 'paypal' && cfg.clientId) m.clientId = cfg.clientId; - if (key === 'stripe' && cfg.publishableKey) m.publishableKey = cfg.publishableKey; - methods.push(m); - } - } - // In demo mode, show all methods as available - if (config.demoMode && methods.length === 0) { - methods.push( - { id: 'stripe', label: 'Card / Apple Pay / Google Pay', icon: 'card' }, - { id: 'paypal', label: 'PayPal / Venmo', icon: 'paypal' }, - { id: 'zelle', label: 'Zelle', icon: 'zelle', address: 'demo@linkedtrust.us' }, - { id: 'cashapp', label: 'Cash App', icon: 'cashapp', tag: '$LinkedTrust' }, - { id: 'crypto', label: 'Crypto (USDT)', icon: 'crypto', address: '0xdemo...', network: 'USDT (Ethereum)' }, - ); - } - res.json({ methods, demoMode: config.demoMode }); -}); - -// ── Author registration ───────────────────────────────────── - -app.post('/api/author/register', (req, res) => { - const { name, email, payoutMethod, payoutAddress } = req.body; - if (!name || !email) return res.status(400).json({ error: 'name and email required' }); - - let slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); - const existing = db.prepare('SELECT slug FROM authors WHERE slug = ?').get(slug); - if (existing) { - const count = db.prepare("SELECT count(*) as n FROM authors WHERE slug LIKE ?").get(slug + '%').n; - slug = slug + '-' + (count + 1); - } - - const byEmail = db.prepare('SELECT * FROM authors WHERE email = ?').get(email); - if (byEmail) { - return res.json({ slug: byEmail.slug, name: byEmail.name, existing: true }); - } - - const id = crypto.randomBytes(8).toString('hex'); - db.prepare('INSERT INTO authors (id, slug, name, email, payout_method, payout_address) VALUES (?, ?, ?, ?, ?, ?)') - .run(id, slug, name, email, payoutMethod || 'paypal', payoutAddress || ''); - - res.json({ slug, name, id }); -}); - -app.get('/api/author/:slug', (req, res) => { - const author = db.prepare('SELECT slug, name, total_received, created_at FROM authors WHERE slug = ?') - .get(req.params.slug); - if (!author) return res.status(404).json({ error: 'author not found' }); - const tipCount = db.prepare('SELECT count(*) as n FROM tips WHERE author_slug = ?').get(req.params.slug).n; - res.json({ ...author, tipCount }); -}); - -// ── Wallet ────────────────────────────────────────────────── - -app.post('/api/wallet/create', (req, res) => { - const { email, name, googleIdToken } = req.body; - - // If Google ID token provided, verify and use email from it - // (For now, trust the email from the frontend — in production, verify the token server-side) - // If email provided, check for existing wallet - if (email) { - const existing = db.prepare('SELECT * FROM wallets WHERE email = ?').get(email); - if (existing) { - return res.json({ token: existing.token, balance: existing.balance, name: existing.name, email: existing.email, existing: true }); - } - } - - const id = crypto.randomBytes(8).toString('hex'); - const token = crypto.randomBytes(32).toString('hex'); - // Anonymous wallets get a placeholder email (never null — allows linking later) - const walletEmail = email || `anon-${id}@wallet.local`; - db.prepare('INSERT INTO wallets (id, email, name, token) VALUES (?, ?, ?, ?)') - .run(id, walletEmail, name || '', token); - - res.json({ token, balance: 0, name: name || '', email: email || null }); -}); - -// Link an email/Google account to an existing wallet (for recovery) -app.post('/api/wallet/link', (req, res) => { - if (!req.wallet) return res.status(401).json({ error: 'not authenticated' }); - const { email, name } = req.body; - if (!email) return res.status(400).json({ error: 'email required' }); - - // Check if another wallet already has this email - const other = db.prepare('SELECT id FROM wallets WHERE email = ? AND id != ?').get(email, req.wallet.id); - if (other) { - return res.status(409).json({ error: 'email already linked to another wallet' }); - } - - db.prepare('UPDATE wallets SET email = ?, name = CASE WHEN name = \'\' THEN ? ELSE name END WHERE id = ?') - .run(email, name || '', req.wallet.id); - res.json({ success: true, email }); -}); - -// Recover wallet by email (returns token) -app.post('/api/wallet/recover', (req, res) => { - const { email } = req.body; - if (!email) return res.status(400).json({ error: 'email required' }); - - const wallet = db.prepare('SELECT * FROM wallets WHERE email = ?').get(email); - if (!wallet || wallet.email.endsWith('@wallet.local')) { - return res.status(404).json({ error: 'no wallet found for this email' }); - } - - // In production: send magic link or verify Google token, don't just return token - // For demo: return token directly - res.json({ token: wallet.token, balance: wallet.balance, name: wallet.name, email: wallet.email }); -}); - -app.get('/api/wallet', (req, res) => { - if (!req.wallet) return res.status(401).json({ error: 'not authenticated' }); - res.json({ - balance: req.wallet.balance, - totalFunded: req.wallet.total_funded, - totalTipped: req.wallet.total_tipped, - name: req.wallet.name, - }); -}); - -// ── Fund wallet ───────────────────────────────────────────── - -// Stripe: create checkout session for wallet funding -app.post('/api/wallet/fund/stripe', async (req, res) => { - if (!req.wallet) return res.status(401).json({ error: 'not authenticated' }); - const { amount } = req.body; - if (!amount || amount <= 0) return res.status(400).json({ error: 'amount required' }); - - if (!stripe) { - // Demo mode — just credit the wallet - return demoFund(req, res, amount, 'stripe'); - } - - try { - const session = await stripe.checkout.sessions.create({ - mode: 'payment', - line_items: [{ - price_data: { - currency: 'usd', - product_data: { name: `SimpleTip wallet — add $${amount}` }, - unit_amount: Math.round(amount * 100), - }, - quantity: 1, - }], - metadata: { wallet_id: req.wallet.id, type: 'fund' }, - success_url: `${config.nodeUrl}/fund-success.html?amount=${amount}`, - cancel_url: `${config.nodeUrl}/fund.html`, - }); - - const fundId = crypto.randomBytes(8).toString('hex'); - db.prepare("INSERT INTO funding (id, wallet_id, amount, method, payment_session, status) VALUES (?, ?, ?, 'stripe', ?, 'pending')") - .run(fundId, req.wallet.id, amount, session.id); - - res.json({ checkoutUrl: session.url, sessionId: session.id }); - } catch (err) { - res.status(500).json({ error: 'Stripe session failed', detail: err.message }); - } -}); - -// PayPal: return client-side info for PayPal JS SDK -app.post('/api/wallet/fund/paypal', async (req, res) => { - if (!req.wallet) return res.status(401).json({ error: 'not authenticated' }); - const { amount } = req.body; - if (!amount || amount <= 0) return res.status(400).json({ error: 'amount required' }); - - if (!config.payments.paypal.enabled) { - return demoFund(req, res, amount, 'paypal'); - } - - // For PayPal, the frontend uses the PayPal JS SDK with our client ID. - // We record a pending funding and confirm it via webhook or client callback. - const fundId = crypto.randomBytes(8).toString('hex'); - db.prepare("INSERT INTO funding (id, wallet_id, amount, method, status) VALUES (?, ?, ?, 'paypal', 'pending')") - .run(fundId, req.wallet.id, amount); - - res.json({ - fundId, - clientId: config.payments.paypal.clientId, - amount, - mode: config.payments.paypal.mode, - }); -}); - -// PayPal: confirm after client-side capture -app.post('/api/wallet/fund/paypal/confirm', (req, res) => { - if (!req.wallet) return res.status(401).json({ error: 'not authenticated' }); - const { fundId, paypalOrderId } = req.body; - - const fund = db.prepare("SELECT * FROM funding WHERE id = ? AND wallet_id = ? AND status = 'pending'") - .get(fundId, req.wallet.id); - if (!fund) return res.status(404).json({ error: 'funding not found' }); - - db.transaction(() => { - db.prepare("UPDATE funding SET status = 'completed', payment_session = ? WHERE id = ?") - .run(paypalOrderId, fundId); - db.prepare('UPDATE wallets SET balance = balance + ?, total_funded = total_funded + ? WHERE id = ?') - .run(fund.amount, fund.amount, req.wallet.id); - })(); - - const updated = db.prepare('SELECT balance FROM wallets WHERE id = ?').get(req.wallet.id); - res.json({ success: true, balance: updated.balance }); -}); - -// Manual methods (Zelle, Cash App, crypto) — record pending, admin confirms later -app.post('/api/wallet/fund/manual', (req, res) => { - if (!req.wallet) return res.status(401).json({ error: 'not authenticated' }); - const { amount, method, reference } = req.body; - if (!amount || !method) return res.status(400).json({ error: 'amount and method required' }); - - if (config.demoMode) { - return demoFund(req, res, amount, method); - } - - const fundId = crypto.randomBytes(8).toString('hex'); - db.prepare("INSERT INTO funding (id, wallet_id, amount, method, payment_session, status) VALUES (?, ?, ?, ?, ?, 'pending_confirmation')") - .run(fundId, req.wallet.id, amount, method, reference || ''); - - res.json({ - fundId, - status: 'pending_confirmation', - message: `Send $${amount} via ${method}. We'll credit your wallet once we confirm receipt.`, - }); -}); - -// Demo funding — instant credit -function demoFund(req, res, amount, method) { - const fundId = crypto.randomBytes(8).toString('hex'); - db.transaction(() => { - db.prepare('UPDATE wallets SET balance = balance + ?, total_funded = total_funded + ? WHERE id = ?') - .run(amount, amount, req.wallet.id); - db.prepare("INSERT INTO funding (id, wallet_id, amount, method, status) VALUES (?, ?, ?, ?, 'completed')") - .run(fundId, req.wallet.id, amount, method + '-demo'); - })(); - - const updated = db.prepare('SELECT balance FROM wallets WHERE id = ?').get(req.wallet.id); - res.json({ success: true, balance: updated.balance, demo: true }); -} - -// Legacy endpoint for backwards compat with demo page -app.post('/api/wallet/fund', (req, res) => { - if (!req.wallet) return res.status(401).json({ error: 'not authenticated' }); - const { amount } = req.body; - if (!amount || amount <= 0) return res.status(400).json({ error: 'amount required' }); - return demoFund(req, res, amount, 'demo'); -}); - -// ── Tipping ───────────────────────────────────────────────── - -// Tip from wallet balance (one click!) -app.post('/api/tip', (req, res) => { - if (!req.wallet) return res.status(401).json({ error: 'not authenticated' }); - - const { author, subject, amount, splitPct } = req.body; - if (!author || !amount || amount <= 0) return res.status(400).json({ error: 'author and amount required' }); - - if (req.wallet.balance < amount) { - return res.status(402).json({ error: 'insufficient_funds', balance: req.wallet.balance }); - } - - const authorRow = db.prepare('SELECT * FROM authors WHERE slug = ?').get(author); - if (!authorRow) return res.status(404).json({ error: 'author not found' }); - - const pct = subject && splitPct != null ? splitPct : 100; - const authorAmount = Math.round(amount * pct) / 100; - const subjectAmount = subject ? Math.round(amount * (100 - pct)) / 100 : 0; - - const tipId = crypto.randomBytes(8).toString('hex'); - db.transaction(() => { - db.prepare('UPDATE wallets SET balance = balance - ?, total_tipped = total_tipped + ? WHERE id = ?') - .run(amount, amount, req.wallet.id); - db.prepare('UPDATE authors SET total_received = total_received + ? WHERE slug = ?') - .run(authorAmount, author); - if (subject) { - db.prepare('UPDATE authors SET total_received = total_received + ? WHERE slug = ?') - .run(subjectAmount, subject); - } - db.prepare("INSERT INTO tips (id, wallet_id, author_slug, subject_slug, amount, author_amount, subject_amount, source) VALUES (?, ?, ?, ?, ?, ?, ?, 'wallet')") - .run(tipId, req.wallet.id, author, subject || null, amount, authorAmount, subjectAmount); - })(); - - const updated = db.prepare('SELECT balance FROM wallets WHERE id = ?').get(req.wallet.id); - res.json({ success: true, tipId, amount, authorAmount, subjectAmount, balance: updated.balance }); -}); - -// Anonymous tip via Stripe (no wallet) -app.post('/api/tip/checkout', async (req, res) => { - const { author, authorName, subject, subjectName, amount, splitPct, returnUrl } = req.body; - if (!author || !amount) return res.status(400).json({ error: 'author and amount required' }); - - const pct = subject && splitPct != null ? splitPct : 100; - const authorAmount = subject ? Math.round(amount * pct) / 100 : amount; - const subjectAmount = subject ? Math.round(amount * (100 - pct)) / 100 : 0; - - const tipId = crypto.randomBytes(8).toString('hex'); - const sessionId = crypto.randomBytes(16).toString('hex'); - - db.prepare("INSERT INTO tips (id, author_slug, subject_slug, amount, author_amount, subject_amount, source, payment_session, status) VALUES (?, ?, ?, ?, ?, ?, 'stripe', ?, 'pending')") - .run(tipId, author, subject || null, amount, authorAmount, subjectAmount, sessionId); - - if (stripe) { - try { - const tipDesc = subject - ? `Tip: ${authorName || author} + ${subjectName || subject}` - : `Tip for ${authorName || author}`; - - const session = await stripe.checkout.sessions.create({ - mode: 'payment', - line_items: [{ - price_data: { - currency: 'usd', - product_data: { name: tipDesc }, - unit_amount: Math.round(amount * 100), - }, - quantity: 1, - }], - metadata: { tip_id: tipId, type: 'tip' }, - success_url: `${config.nodeUrl}/tip-success.html?amount=${amount}&author=${encodeURIComponent(authorName || author)}`, - cancel_url: returnUrl || config.nodeUrl, - }); - - // Update with real Stripe session ID - db.prepare('UPDATE tips SET payment_session = ? WHERE id = ?').run(session.id, tipId); - - return res.json({ checkoutUrl: session.url, sessionId: session.id, tipId }); - } catch (err) { - return res.status(500).json({ error: 'Stripe failed', detail: err.message }); - } - } - - // Demo mode - const origin = returnUrl ? new URL(returnUrl).origin : config.nodeUrl; - const checkoutUrl = `${origin}/simpletip/checkout.html?session=${sessionId}&amount=${amount}&author=${encodeURIComponent(authorName || author)}&tip=${tipId}`; - res.json({ checkoutUrl, sessionId, tipId }); -}); - -// Confirm a checkout (demo mode or manual confirmation) -app.post('/api/tip/confirm', (req, res) => { - const { tipId, sessionId } = req.body; - const tip = db.prepare("SELECT * FROM tips WHERE id = ? AND payment_session = ? AND status = 'pending'") - .get(tipId, sessionId); - if (!tip) return res.status(404).json({ error: 'tip not found or already confirmed' }); - - db.transaction(() => { - db.prepare("UPDATE tips SET status = 'completed' WHERE id = ?").run(tipId); - db.prepare('UPDATE authors SET total_received = total_received + ? WHERE slug = ?') - .run(tip.author_amount, tip.author_slug); - if (tip.subject_slug) { - db.prepare('UPDATE authors SET total_received = total_received + ? WHERE slug = ?') - .run(tip.subject_amount, tip.subject_slug); - } - })(); - - res.json({ success: true }); -}); - -// ── Stripe webhook ────────────────────────────────────────── - -async function handleStripeWebhook(req, res) { - if (!stripe) return res.status(400).send('Stripe not configured'); - - let event; - try { - if (config.payments.stripe.webhookSecret) { - event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], config.payments.stripe.webhookSecret); - } else { - event = JSON.parse(req.body); - } - } catch (err) { - return res.status(400).send(`Webhook error: ${err.message}`); - } - - if (event.type === 'checkout.session.completed') { - const session = event.data.object; - const meta = session.metadata || {}; - - if (meta.type === 'fund') { - // Wallet funding - const fund = db.prepare("SELECT * FROM funding WHERE payment_session = ? AND status = 'pending'") - .get(session.id); - if (fund) { - db.transaction(() => { - db.prepare("UPDATE funding SET status = 'completed' WHERE id = ?").run(fund.id); - db.prepare('UPDATE wallets SET balance = balance + ?, total_funded = total_funded + ? WHERE id = ?') - .run(fund.amount, fund.amount, fund.wallet_id); - })(); - } - } else if (meta.type === 'tip') { - // Direct tip - const tip = db.prepare("SELECT * FROM tips WHERE payment_session = ? AND status = 'pending'") - .get(session.id); - if (tip) { - db.transaction(() => { - db.prepare("UPDATE tips SET status = 'completed' WHERE id = ?").run(tip.id); - db.prepare('UPDATE authors SET total_received = total_received + ? WHERE slug = ?') - .run(tip.author_amount, tip.author_slug); - if (tip.subject_slug) { - db.prepare('UPDATE authors SET total_received = total_received + ? WHERE slug = ?') - .run(tip.subject_amount, tip.subject_slug); - } - })(); - } - } - } - - res.json({ received: true }); -} - -// ── Wallet tips history ───────────────────────────────────── - -app.get('/api/wallet/tips', (req, res) => { - if (!req.wallet) return res.status(401).json({ error: 'not authenticated' }); - const tips = db.prepare(` - SELECT t.*, a.name as author_name - FROM tips t LEFT JOIN authors a ON t.author_slug = a.slug - WHERE t.wallet_id = ? ORDER BY t.created_at DESC LIMIT 50 - `).all(req.wallet.id); - res.json(tips); -}); - -// ── Stats ─────────────────────────────────────────────────── - -app.get('/api/stats', (req, res) => { - const totalTips = db.prepare("SELECT count(*) as n, sum(amount) as total FROM tips WHERE status = 'completed'").get(); - const topAuthors = db.prepare(` - SELECT slug, name, total_received, (SELECT count(*) FROM tips WHERE author_slug = authors.slug) as tip_count - FROM authors WHERE total_received > 0 ORDER BY total_received DESC LIMIT 10 - `).all(); - res.json({ totalTips: totalTips.n, totalAmount: totalTips.total || 0, topAuthors }); -}); - -// ── Pledges ───────────────────────────────────────────────── - -// Create a pledge (no balance needed — just intent) -app.post('/api/pledge', (req, res) => { - if (!req.wallet) return res.status(401).json({ error: 'not authenticated' }); - - const { author, subject, amount, splitPct } = req.body; - if (!author || !amount || amount <= 0) return res.status(400).json({ error: 'author and amount required' }); - - const pct = subject && splitPct != null ? splitPct : 100; - const authorAmount = Math.round(amount * pct) / 100; - const subjectAmount = subject ? Math.round(amount * (100 - pct)) / 100 : 0; - - const pledgeId = crypto.randomBytes(8).toString('hex'); - db.prepare("INSERT INTO pledges (id, wallet_id, author_slug, subject_slug, amount, author_amount, subject_amount) VALUES (?, ?, ?, ?, ?, ?, ?)") - .run(pledgeId, req.wallet.id, author, subject || null, amount, authorAmount, subjectAmount); - - // Return total pending pledges for this wallet - const pending = db.prepare("SELECT count(*) as n, sum(amount) as total FROM pledges WHERE wallet_id = ? AND status = 'pending'") - .get(req.wallet.id); - - res.json({ success: true, pledgeId, pendingCount: pending.n, pendingTotal: pending.total || 0 }); -}); - -// Get pending pledges for the current wallet -app.get('/api/pledges', (req, res) => { - if (!req.wallet) return res.status(401).json({ error: 'not authenticated' }); - const pledges = db.prepare(` - SELECT p.*, a.name as author_name - FROM pledges p LEFT JOIN authors a ON p.author_slug = a.slug - WHERE p.wallet_id = ? AND p.status = 'pending' ORDER BY p.created_at DESC - `).all(req.wallet.id); - const total = pledges.reduce((s, p) => s + p.amount, 0); - res.json({ pledges, total }); -}); - -// Fulfill all pending pledges (called after wallet is funded) -app.post('/api/pledges/fulfill', (req, res) => { - if (!req.wallet) return res.status(401).json({ error: 'not authenticated' }); - - const pending = db.prepare("SELECT * FROM pledges WHERE wallet_id = ? AND status = 'pending' ORDER BY created_at ASC") - .all(req.wallet.id); - - if (pending.length === 0) return res.json({ fulfilled: 0 }); - - const totalNeeded = pending.reduce((s, p) => s + p.amount, 0); - if (req.wallet.balance < totalNeeded) { - return res.status(402).json({ - error: 'insufficient_funds', - balance: req.wallet.balance, - needed: totalNeeded, - pledgeCount: pending.length, - }); - } - - let fulfilled = 0; - db.transaction(() => { - for (const p of pending) { - if (req.wallet.balance < p.amount) break; // stop if can't cover next pledge - - const tipId = crypto.randomBytes(8).toString('hex'); - db.prepare("INSERT INTO tips (id, wallet_id, author_slug, subject_slug, amount, author_amount, subject_amount, source) VALUES (?, ?, ?, ?, ?, ?, ?, 'pledge')") - .run(tipId, req.wallet.id, p.author_slug, p.subject_slug, p.amount, p.author_amount, p.subject_amount); - db.prepare('UPDATE wallets SET balance = balance - ?, total_tipped = total_tipped + ? WHERE id = ?') - .run(p.amount, p.amount, req.wallet.id); - db.prepare('UPDATE authors SET total_received = total_received + ? WHERE slug = ?') - .run(p.author_amount, p.author_slug); - if (p.subject_slug) { - db.prepare('UPDATE authors SET total_received = total_received + ? WHERE slug = ?') - .run(p.subject_amount, p.subject_slug); - } - db.prepare("UPDATE pledges SET status = 'fulfilled', fulfilled_tip_id = ? WHERE id = ?") - .run(tipId, p.id); - fulfilled++; - // Update in-memory balance for loop - req.wallet.balance -= p.amount; - } - })(); - - const updated = db.prepare('SELECT balance FROM wallets WHERE id = ?').get(req.wallet.id); - res.json({ fulfilled, balance: updated.balance }); -}); - -// ── Bluesky OAuth ─────────────────────────────────────────── - -// Serve client-metadata.json for ATProto OAuth -app.get('/client-metadata.json', (req, res) => { - const nodeUrl = config.nodeUrl; - res.json({ - client_id: `${nodeUrl}/client-metadata.json`, - client_name: config.nodeName || 'SimpleTip', - client_uri: nodeUrl, - redirect_uris: [`${nodeUrl}/api/auth/bluesky/callback`], - scope: 'atproto', - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - token_endpoint_auth_method: 'none', - application_type: 'web', - dpop_bound_access_tokens: true, - }); -}); - -// Initialize ATProto OAuth client (async, called at startup) -async function setupBlueskyOAuth() { - try { - const nodeUrl = config.nodeUrl; - const isHttps = nodeUrl.startsWith('https://') && !nodeUrl.includes('localhost'); - if (!isHttps) { - console.log('Bluesky OAuth: skipped (requires HTTPS domain)'); - return; - } - - const { NodeOAuthClient: NOC } = await import('@atproto/oauth-client-node'); - const { SimpleStoreMemory: SSM } = await import('@atproto-labs/simple-store-memory'); - NodeOAuthClient = NOC; - SimpleStoreMemory = SSM; - - const stateStore = new SimpleStoreMemory({ max: 100, ttl: 10 * 60 * 1000 }); - const sessionStore = new SimpleStoreMemory({ max: 100 }); - - oauthClient = new NodeOAuthClient({ - clientMetadata: { - client_id: `${nodeUrl}/client-metadata.json`, - client_name: config.nodeName || 'SimpleTip', - client_uri: nodeUrl, - redirect_uris: [`${nodeUrl}/api/auth/bluesky/callback`], - scope: 'atproto', - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - token_endpoint_auth_method: 'none', - application_type: 'web', - dpop_bound_access_tokens: true, - }, - stateStore, - sessionStore, - }); - - console.log('Bluesky OAuth configured'); - } catch (err) { - console.log('Bluesky OAuth setup failed:', err.message); - } -} - -// Start Bluesky OAuth flow -app.get('/api/auth/bluesky', async (req, res) => { - const { handle } = req.query; - if (!handle) return res.status(400).json({ error: 'handle required' }); - - if (!oauthClient) { - return res.status(503).json({ error: 'Bluesky OAuth not available (needs HTTPS domain)' }); - } - - try { - const url = await oauthClient.authorize(handle, { - scope: 'atproto', - }); - res.json({ url: url.toString() }); - } catch (err) { - res.status(500).json({ error: 'OAuth failed', detail: err.message }); - } -}); - -// Bluesky OAuth callback -app.get('/api/auth/bluesky/callback', async (req, res) => { - if (!oauthClient) return res.status(503).send('OAuth not available'); - - try { - const params = new URLSearchParams(req.url.split('?')[1] || ''); - const { session } = await oauthClient.callback(params); - const did = session.did; - - // Try to get profile for display name and handle - let handle = did; - let displayName = ''; - try { - const agent = await oauthClient.restore(did); - // Use the ATProto API to get profile - const { BskyAgent } = await import('@atproto/api'); - const bsky = new BskyAgent({ service: 'https://public.api.bsky.app' }); - const profile = await bsky.getProfile({ actor: did }); - handle = profile.data.handle; - displayName = profile.data.displayName || handle; - } catch (e) { - console.log('Could not fetch profile:', e.message); - } - - // Find or create wallet for this DID - let wallet = db.prepare('SELECT * FROM wallets WHERE did = ?').get(did); - if (!wallet) { - // Check if there's an anonymous wallet from this browser session (via state param) - // For now, just create a new wallet - const id = crypto.randomBytes(8).toString('hex'); - const token = crypto.randomBytes(32).toString('hex'); - const email = `${handle}@bsky.social`; - db.prepare('INSERT INTO wallets (id, email, name, token, did, handle) VALUES (?, ?, ?, ?, ?, ?)') - .run(id, email, displayName, token, did, handle); - wallet = db.prepare('SELECT * FROM wallets WHERE id = ?').get(id); - } - - // Redirect to login-success page with token (popup will postMessage to opener) - const successUrl = `${config.nodeUrl}/login-success.html?token=${wallet.token}&name=${encodeURIComponent(wallet.name || handle)}&handle=${encodeURIComponent(handle)}&did=${encodeURIComponent(did)}&balance=${wallet.balance}`; - res.redirect(successUrl); - } catch (err) { - console.error('Bluesky callback error:', err); - res.redirect(`${config.nodeUrl}/login.html?error=${encodeURIComponent(err.message)}`); - } -}); - -// Link Bluesky DID to existing wallet -app.post('/api/wallet/link-bluesky', (req, res) => { - if (!req.wallet) return res.status(401).json({ error: 'not authenticated' }); - const { did, handle } = req.body; - if (!did) return res.status(400).json({ error: 'did required' }); - - const other = db.prepare('SELECT id FROM wallets WHERE did = ? AND id != ?').get(did, req.wallet.id); - if (other) { - return res.status(409).json({ error: 'DID already linked to another wallet' }); - } - - db.prepare('UPDATE wallets SET did = ?, handle = ? WHERE id = ?') - .run(did, handle || '', req.wallet.id); - res.json({ success: true, did, handle }); -}); - -// Auth status check (for widget to know if user is logged in) -app.get('/api/auth/status', (req, res) => { - if (!req.wallet) return res.json({ authenticated: false }); - res.json({ - authenticated: true, - balance: req.wallet.balance, - name: req.wallet.name, - handle: req.wallet.handle || null, - did: req.wallet.did || null, - hasFunds: req.wallet.balance > 0, - }); -}); - -// ── Author dashboard ──────────────────────────────────────── - -app.get('/api/author/:slug/dashboard', (req, res) => { - const author = db.prepare('SELECT * FROM authors WHERE slug = ?').get(req.params.slug); - if (!author) return res.status(404).json({ error: 'author not found' }); - - const tips = db.prepare(` - SELECT t.amount, t.author_amount, t.subject_amount, t.source, t.created_at, - w.name as tipper_name, w.handle as tipper_handle - FROM tips t LEFT JOIN wallets w ON t.wallet_id = w.id - WHERE t.author_slug = ? AND t.status = 'completed' - ORDER BY t.created_at DESC LIMIT 100 - `).all(req.params.slug); - - const pendingPledges = db.prepare(` - SELECT p.amount, p.author_amount, p.created_at, - w.name as pledger_name, w.handle as pledger_handle - FROM pledges p LEFT JOIN wallets w ON p.wallet_id = w.id - WHERE p.author_slug = ? AND p.status = 'pending' - ORDER BY p.created_at DESC - `).all(req.params.slug); - - const stats = db.prepare(` - SELECT count(*) as tip_count, sum(author_amount) as total_received - FROM tips WHERE author_slug = ? AND status = 'completed' - `).get(req.params.slug); - - const pledgeStats = db.prepare(` - SELECT count(*) as pledge_count, sum(amount) as total_pledged - FROM pledges WHERE author_slug = ? AND status = 'pending' - `).get(req.params.slug); - - res.json({ - author: { slug: author.slug, name: author.name, created_at: author.created_at }, - tips, - pendingPledges, - stats: { - tipCount: stats.tip_count, - totalReceived: stats.total_received || 0, - pledgeCount: pledgeStats.pledge_count, - totalPledged: pledgeStats.total_pledged || 0, - }, - }); -}); - -// ── Admin: confirm manual funding ─────────────────────────── - -app.post('/api/admin/confirm-funding', (req, res) => { - // TODO: add admin auth - const { fundId } = req.body; - const fund = db.prepare("SELECT * FROM funding WHERE id = ? AND status = 'pending_confirmation'").get(fundId); - if (!fund) return res.status(404).json({ error: 'funding not found' }); - - db.transaction(() => { - db.prepare("UPDATE funding SET status = 'completed' WHERE id = ?").run(fundId); - db.prepare('UPDATE wallets SET balance = balance + ?, total_funded = total_funded + ? WHERE id = ?') - .run(fund.amount, fund.amount, fund.wallet_id); - })(); - - res.json({ success: true }); -}); - -// ── Start ──────────────────────────────────────────────────── - -app.listen(PORT, '127.0.0.1', async () => { - console.log(`SimpleTip backend on http://127.0.0.1:${PORT}`); - console.log(`Mode: ${config.demoMode ? 'DEMO' : 'LIVE'}`); - const enabled = Object.entries(config.payments).filter(([, v]) => v.enabled).map(([k]) => k); - console.log(`Payment methods: ${enabled.length ? enabled.join(', ') : 'none (demo mode — all simulated)'}`); - await setupBlueskyOAuth(); -}); diff --git a/public/dashboard.html b/public/dashboard.html index c4bd3f3..1ef3fcd 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -3,7 +3,7 @@ -Author Dashboard — SimpleTip +Receiver Dashboard — SimpleTip +
-

Author Dashboard

-

See your tips and pledges.

+

Receiver Dashboard

+

See your tips, pledges, and payouts.

- +
+ + diff --git a/public/fund.html b/public/fund.html index 0118cea..8ae5c61 100644 --- a/public/fund.html +++ b/public/fund.html @@ -35,7 +35,6 @@ padding: 14px; margin-top: 12px; font-size: 0.85rem; color: #92400e; line-height: 1.4; } .manual-info.show { display: block; } - .manual-info .addr { font-family: monospace; font-weight: 600; background: #fef3c7; padding: 4px 8px; border-radius: 4px; display: inline-block; margin: 4px 0; user-select: all; } .manual-info button { background: #f59e0b; color: #fff; border: none; border-radius: 6px; padding: 8px 16px; font-size: 0.85rem; cursor: pointer; margin-top: 8px; } .demo-badge { background: #dbeafe; color: #1d4ed8; padding: 4px 10px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; text-align: center; margin-bottom: 12px; } @@ -44,7 +43,6 @@ .status.success { color: #16a34a; font-weight: 600; } .status.error { color: #ef4444; } - /* Protect wallet nudge */ .protect-nudge { display: none; background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px; padding: 12px; margin-top: 12px; font-size: 0.82rem; color: #166534; text-align: center; @@ -62,20 +60,10 @@ display: inline-block; margin-top: 6px; font-size: 0.72rem; color: #999; cursor: pointer; text-decoration: underline; } - - /* Google Sign-In style button */ - .google-btn { - display: flex; align-items: center; justify-content: center; gap: 8px; - background: #fff; border: 1px solid #dadce0; border-radius: 8px; - padding: 10px 16px; cursor: pointer; font-size: 0.88rem; font-weight: 500; - color: #3c4043; width: 100%; margin-bottom: 8px; - transition: box-shadow 0.15s; - } - .google-btn:hover { box-shadow: 0 1px 3px rgba(0,0,0,0.15); } - .google-btn svg { width: 18px; height: 18px; } +

Add Funds to Your Wallet

Choose an amount and payment method.

@@ -91,14 +79,11 @@

Add Funds to Your Wallet

-
- -
+
-
Protect your wallet
Add your email so you can recover your balance from any device. @@ -114,7 +99,8 @@

Add Funds to Your Wallet

let wallet = null; let demoMode = false; - // Load wallet from localStorage + function cents(v) { return '$' + (v / 100).toFixed(2); } + try { const raw = localStorage.getItem('simpletip_wallet'); if (raw) wallet = JSON.parse(raw); @@ -129,16 +115,13 @@

Add Funds to Your Wallet

}); }); - // Icon map const icons = { card: '\u{1F4B3}', stripe: '\u{1F4B3}', paypal: '\u{1F17F}\uFE0F', zelle: '\u26A1', cashapp: '\u{1F4B2}', crypto: '\u{1FA99}', mpesa: '\u{1F4F1}', ilp: '\u{1F310}', }; - // If no wallet exists at all, create one automatically async function ensureWallet() { if (wallet && wallet.token) return; - const resp = await fetch(`${API}/wallet/create`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -151,16 +134,17 @@

Add Funds to Your Wallet

} } - // Load available methods async function loadMethods() { await ensureWallet(); if (wallet) { - const w = await fetch(`${API}/wallet`, { headers: { Authorization: `Bearer ${wallet.token}` } }).then(r => r.json()); - if (w.balance != null) { - wallet.balance = w.balance; - localStorage.setItem('simpletip_wallet', JSON.stringify(wallet)); - } + try { + const w = await fetch(`${API}/wallet`, { headers: { Authorization: `Bearer ${wallet.token}` } }).then(r => r.json()); + if (w.balance != null) { + wallet.balance = w.balance; + localStorage.setItem('simpletip_wallet', JSON.stringify(wallet)); + } + } catch (e) {} } updateBalance(); @@ -193,20 +177,19 @@

Add Funds to Your Wallet

if (m.id === 'cashapp') return 'Send via Cash App'; if (m.id === 'crypto') return m.network || 'USDT / USDC'; if (m.id === 'mpesa') return 'Mobile money'; - if (m.id === 'ilp') return 'Interledger Open Payments'; return ''; } function updateBalance() { - document.getElementById('balance').textContent = wallet ? `$${(wallet.balance || 0).toFixed(2)}` : '$0.00'; + // balance is stored in cents from API + const bal = wallet ? (wallet.balance || 0) : 0; + document.getElementById('balance').textContent = cents(bal); } - // Check if wallet is anonymous (no real email linked) function isAnonymousWallet() { return !wallet || !wallet.email || wallet.email.endsWith('@wallet.local'); } - // Show "protect your wallet" nudge after funding function showProtectNudge() { if (isAnonymousWallet()) { document.getElementById('protectNudge').classList.add('show'); @@ -255,39 +238,9 @@

Add Funds to Your Wallet

return; } - // PayPal - if (m.id === 'paypal') { - status.textContent = 'Opening PayPal...'; - const resp = await fetch(`${API}/wallet/fund/paypal`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${wallet.token}` }, - body: JSON.stringify({ amount }), - }).then(r => r.json()); - - if (resp.success) { - wallet.balance = resp.balance; - localStorage.setItem('simpletip_wallet', JSON.stringify(wallet)); - updateBalance(); - status.textContent = `$${amount} added (demo)`; - status.className = 'status success'; - notifyOpener(); - showProtectNudge(); - } else if (resp.clientId) { - status.textContent = 'PayPal integration — coming soon. Use card for now.'; - } - return; - } - - // Manual methods (Zelle, Cash App, Crypto) - if (['zelle', 'cashapp', 'crypto'].includes(m.id)) { - let html = ''; - if (m.id === 'zelle') { - html = `Send $${amount} via Zelle to:
${m.address || 'not configured'}
Include your email in the memo so we can match it to your wallet.`; - } else if (m.id === 'cashapp') { - html = `Send $${amount} via Cash App to:
${m.tag || 'not configured'}
Include your email in the note.`; - } else if (m.id === 'crypto') { - html = `Send $${amount} in ${m.network || 'USDT'} to:
${m.address || 'not configured'}
We'll credit your wallet once confirmed on-chain.`; - } + // Manual methods (Zelle, PayPal, etc.) + if (['zelle', 'cashapp', 'paypal'].includes(m.id)) { + let html = `Send $${amount} via ${m.label}.
Include your email in the memo so we can match it to your wallet.`; if (demoMode) { html += `
`; @@ -300,13 +253,12 @@

Add Funds to Your Wallet

return; } - // Fallback status.textContent = `${m.label} — coming soon`; } - // Manual method: mark as sent (creates pending funding) + // Manual method: create pending funding record async function submitManual(method, amount) { - const resp = await fetch(`${API}/wallet/fund/manual`, { + const resp = await fetch(`${API}/wallet/fund`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${wallet.token}` }, body: JSON.stringify({ amount, method }), @@ -314,18 +266,18 @@

Add Funds to Your Wallet

const status = document.getElementById('status'); if (resp.fundId) { - status.textContent = resp.message; + status.textContent = resp.message || 'Funding pending confirmation'; status.className = 'status success'; document.getElementById('manualInfo').classList.remove('show'); } } - // Demo: instant credit for manual methods + // Demo: instant credit async function confirmManual(method, amount) { const resp = await fetch(`${API}/wallet/fund`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${wallet.token}` }, - body: JSON.stringify({ amount }), + body: JSON.stringify({ amount, method }), }).then(r => r.json()); if (resp.success) { @@ -346,12 +298,12 @@

Add Funds to Your Wallet

} } - // Link email to wallet + // Link email via /api/wallet/contact document.getElementById('linkBtn').addEventListener('click', async () => { const email = document.getElementById('linkEmail').value.trim(); if (!email) return; - const resp = await fetch(`${API}/wallet/link`, { + const resp = await fetch(`${API}/wallet/contact`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${wallet.token}` }, body: JSON.stringify({ email }), @@ -365,7 +317,7 @@

Add Funds to Your Wallet

document.getElementById('status').className = 'status success'; notifyOpener(); } else { - document.getElementById('status').textContent = resp.error || 'Could not link email'; + document.getElementById('status').textContent = resp.detail || 'Could not link email'; document.getElementById('status').className = 'status error'; } }); @@ -374,7 +326,6 @@

Add Funds to Your Wallet

document.getElementById('protectNudge').classList.remove('show'); }); - // Make confirmManual available globally for onclick window.confirmManual = confirmManual; window.submitManual = submitManual; diff --git a/public/index.html b/public/index.html index c6e5901..af15a67 100644 --- a/public/index.html +++ b/public/index.html @@ -13,20 +13,11 @@ .section { background: #fff; border-radius: 10px; padding: 20px; margin-bottom: 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); } .section h2 { font-size: 1rem; color: #3f2534; margin-bottom: 10px; } .section p { font-size: 0.88rem; color: #555; line-height: 1.5; margin-bottom: 12px; } - .nav { display: flex; gap: 10px; margin-bottom: 24px; flex-wrap: wrap; } - .nav a { background: #3f2534; color: #fff; padding: 7px 14px; border-radius: 6px; text-decoration: none; font-size: 0.82rem; } - .nav a:hover { background: #5a3a4c; } .article-mock { background: #fafafa; border: 1px solid #e5e7eb; border-radius: 8px; padding: 18px; margin-bottom: 14px; } .article-mock h3 { font-size: 0.95rem; margin-bottom: 6px; } .article-mock .byline { font-size: 0.78rem; color: #888; margin-bottom: 10px; } .article-mock .body { font-size: 0.88rem; color: #444; line-height: 1.5; } - .demo-wallet-controls { background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 8px; padding: 14px; margin-bottom: 16px; font-size: 0.85rem; } - .demo-wallet-controls button { background: #00b2e5; color: #fff; border: none; border-radius: 5px; padding: 6px 14px; font-size: 0.82rem; cursor: pointer; margin: 4px 4px 4px 0; } - .demo-wallet-controls button:hover { background: #0090c0; } - .demo-wallet-controls button.danger { background: #ef4444; } - .demo-wallet-controls .balance { font-weight: 600; color: #3f2534; } - - /* Embed instructions */ + .anon-label { display: inline-block; background: #f3f4f6; color: #888; font-size: 0.72rem; font-weight: 600; padding: 3px 10px; border-radius: 4px; margin-bottom: 10px; } .embed-section { background: #1a1a2e; color: #e2e8f0; border-radius: 10px; padding: 24px; margin-bottom: 20px; } .embed-section h2 { color: #fff; font-size: 1.1rem; margin-bottom: 12px; } .embed-section p { font-size: 0.88rem; color: #94a3b8; line-height: 1.5; margin-bottom: 12px; } @@ -56,19 +47,70 @@ +

SimpleTip

One-click tipping for any website. Fund once, tip everywhere.

- + + +
+

Try it: Tip the author

+

Click a dollar amount. If you have a wallet with funds, it tips instantly. Otherwise you'll be prompted to add funds.

+ +
+

Detained and Forgotten: Families Fighting for Answers

+ +
Across three continents, families of the wrongfully detained are banding together to demand accountability. What started as a Facebook group of desperate mothers has become a global movement documenting cases, pressuring governments, and refusing to let the world look away...
+
+ + + +
+ +
+

Try it: Split tip — author + subject

+

When the article covers someone who could use support, readers split between the journalist and the subject.

+ +
+

Inside Sudan's Emergency Rooms: Doctors Without Supplies

+ +
In the besieged hospitals of Khartoum, medical staff work 20-hour shifts with dwindling supplies. Dr. Amira Hassan hasn't slept in two days. "We need everything," she says...
+
+ + + +
+ + + +
+

What a new visitor sees

+

Before the reader has a wallet, the widget looks like this. Clicking any amount starts the signup flow.

+ First-time visitor view + + + +
+ +
+ +
+ + -

Embed SimpleTip in your site

Add tipping to any article in two lines of HTML. Works everywhere JavaScript runs.

@@ -84,7 +126,7 @@

Embed SimpleTip in your site

1 - Register as an author to get your slug + Register as a receiver and add a payout method
@@ -93,16 +135,16 @@

Embed SimpleTip in your site

<script src="https://demos.linkedtrust.us/simpletip/simpletip.js"></script> -<simple-tip author="your-slug" author-name="Your Name"></simple-tip>
+<simple-tip receiver="your-slug" receiver-name="Your Name"></simple-tip>
+ - Split tips (author + article subject): + Split tips between multiple receivers:
<simple-tip - author="your-slug" - author-name="Your Name" + receiver="journalist-slug" + receiver-name="Journalist Name" subject="subject-slug" subject-label="Subject Name or Cause"> </simple-tip>
@@ -112,72 +154,74 @@

Embed SimpleTip in your site

The script only needs to be included once per page.

- - - -
- Demo wallet controls (for testing — readers wouldn't see this)
- No wallet · $0.00 -
- - - - -
+ - -
-

Try it: Tip the author

-

Embedded at the bottom of any article. Reader clicks a dollar amount — tip or pledge goes through instantly.

+
+

Open protocol, verifiable payouts

+

SimpleTip is decentralized. Anyone can run a node — a press freedom organization, a university, a journalism co-op. Each node handles payments with whatever methods work in their region. The widget is the same everywhere.

-
-

Detained and Forgotten: Families Fighting for Answers

- -
Across three continents, families of the wrongfully detained are banding together to demand accountability. What started as a Facebook group of desperate mothers has become a global movement documenting cases, pressuring governments, and refusing to let the world look away...
-
+

What makes this work is accountability. Every payout from a SimpleTip node is published as a LinkedClaim — an open attestation on ATProto. Not an internal record. A public, verifiable statement on the decentralized network: this node paid this person this amount on this date.

- - +

That means:

+
    +
  • Receivers can point to a claim wall showing every verified payout — not self-reported, node-attested
  • +
  • Donors can verify their contributions actually reached the intended recipient
  • +
  • Newsrooms and NGOs can audit disbursements without requesting reports from anyone
  • +
  • If a node stops operating, the attestation history lives on ATProto — it's portable, not locked in
  • +
+ +

No node is trusted by default. Every node builds its track record in public, one attestation at a time. The LinkedClaims spec is open. The data lives on ATProto. Anyone can verify it.

+ +

+ LinkedClaims.com · + Source code · + Built by LinkedTrust +

- + +
-

Try it: Split tip — author + subject

-

When the article covers someone who could use support, readers split between the journalist and the subject.

+

Why not just use Ko-fi or CashApp?

+

You can. They work. Here's what's different about SimpleTip:

-
-

Inside Sudan's Emergency Rooms: Doctors Without Supplies

- -
In the besieged hospitals of Khartoum, medical staff work 20-hour shifts with dwindling supplies. Dr. Amira Hassan hasn't slept in two days. "We need everything," she says...
-
+

Ko-fi, Buy Me a Coffee, Patreon — the donor leaves your site, goes to theirs, pays through their platform. They hold the money. They set the rules. If they shut down or change terms, you lose your channel. Your supporters become their users.

- - +

CashApp, Venmo, PayPal.me — peer-to-peer, but no embed. No "tip the three people who made this article" flow. No record of what the payment was for. Fine for friends splitting dinner, not for transparent support of journalism.

+ +

SimpleTip — the widget lives on your site. Tips can split between multiple receivers. Received funds can be re-tipped to other creators without withdrawing first. And anyone can run a node.

+ +

None of the above work everywhere. Stripe Connect is fine for US and EU receivers. But a freelance writer in Lagos doesn't have a Stripe account. A fixer in Bogotá isn't on Venmo. For many receivers, the practical options are M-Pesa, MoneyGram, or USDT — not because it's dangerous, just because that's how payments work where they are. M-Pesa is how people get paid in East Africa. MoneyGram is how families send remittances. USDT is how freelancers in places with currency controls actually receive dollars.

+ +

SimpleTip nodes support whatever payout methods work for their receivers: PayPal, Venmo, Zelle, ACH, M-Pesa, MoneyGram, USDT, Tether on multiple networks. Payout details are encrypted at rest. A node in Nairobi handles M-Pesa. A node in New York handles Stripe Connect. The donor sees the same widget either way.

+ +

That brings up the real question. Anyone with a Stripe account can hold funds on behalf of others. That's not unique to us — it's how all of the above work too. The question is: why should anyone trust the operator?

+ +

Most platforms answer that with brand recognition. We answer it with verifiable records.

+ +

When a receiver gets paid, they confirm it — a signed attestation published on ATProto as a LinkedClaim. Not an internal database entry. A public, independently verifiable statement: I received $X from this node on this date. That record lives on the decentralized network — it doesn't disappear if the node shuts down.

+ +

That gives you a real compliance trail. Proof of delivery for the operator. Receipts for donors. An audit trail that regulators or journalists can verify without asking anyone for access. And for receivers, a portable track record of support that follows them regardless of which platform they use.

+ +

A node that consistently pays out builds a public record. One that doesn't, has a visible gap. No trust required — just evidence.

- -
-

Minimal embed

-

Just a name. Two lines of code.

+ - +
+

Related open-source projects:

+ Liberapay — recurring donations, zero platform fees, Stripe + PayPal (not embeddable)
+ Open Collective — fiscal hosting for open-source projects and communities
+ BTCPay Server — self-hosted Bitcoin/Lightning payment processing
+ Open Payment Host — self-hosted alternative to Buy Me a Coffee (Go, AGPL)
+
diff --git a/public/login.html b/public/login.html index 4770007..7d28be4 100644 --- a/public/login.html +++ b/public/login.html @@ -62,6 +62,7 @@ .status { text-align: center; font-size: 0.82rem; margin-top: 12px; min-height: 1.2em; } .status.error { color: #ef4444; } + .status.success { color: #16a34a; } .status.info { color: #888; } .back-link { display: block; text-align: center; font-size: 0.75rem; color: #999; margin-top: 12px; cursor: pointer; } @@ -69,6 +70,7 @@ +

Sign in to SimpleTip

Your wallet works across every site with SimpleTip.

@@ -137,7 +139,7 @@

Sign in to SimpleTip

document.getElementById('mainButtons').style.display = ''; }); - // Bluesky OAuth submit + // Bluesky — placeholder for ATProto OAuth (not yet implemented server-side) document.getElementById('handleSubmit').addEventListener('click', async () => { const handle = document.getElementById('handleInput').value.trim(); if (!handle) return; @@ -149,27 +151,49 @@

Sign in to SimpleTip

status.textContent = ''; try { - const resp = await fetch(`${API}/auth/bluesky?handle=${encodeURIComponent(handle)}`); - const data = await resp.json(); + // Create/recover wallet using handle as identifier + let resp = await fetch(`${API}/wallet/create`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ handle }), + }); + let data = await resp.json(); - if (data.url) { - // Redirect to Bluesky authorization page - window.location.href = data.url; + if (data.token) { + const wallet = { + token: data.token, + balance: data.balance || 0, + name: data.name || '', + handle: handle, + }; + localStorage.setItem('simpletip_wallet', JSON.stringify(wallet)); + + // Save handle as contact info + await fetch(`${API}/wallet/contact`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${data.token}` }, + body: JSON.stringify({ handle, display_name: handle }), + }); + + if (window.opener) { + window.opener.postMessage({ type: 'simpletip-auth', wallet }, '*'); + } + status.textContent = 'Signed in! You can close this window.'; + status.className = 'status success'; + setTimeout(() => window.close(), 1000); } else { - status.textContent = data.error || 'Failed to connect'; + status.textContent = data.detail || 'Could not create wallet'; status.className = 'status error'; - btn.disabled = false; - btn.textContent = 'Sign in with Bluesky'; } } catch (err) { status.textContent = 'Connection error'; status.className = 'status error'; - btn.disabled = false; - btn.textContent = 'Sign in with Bluesky'; } + + btn.disabled = false; + btn.textContent = 'Sign in with Bluesky'; }); - // Enter key for handle document.getElementById('handleInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') document.getElementById('handleSubmit').click(); }); @@ -211,13 +235,13 @@

Sign in to SimpleTip

name: data.name || name || '', email: data.email || email, }; - // Save to our domain's localStorage localStorage.setItem('simpletip_wallet', JSON.stringify(wallet)); - // Notify opener if (window.opener) { window.opener.postMessage({ type: 'simpletip-auth', wallet }, '*'); } - window.close(); + status.textContent = 'Signed in! You can close this window.'; + status.className = 'status success'; + setTimeout(() => window.close(), 1000); } else { status.textContent = 'Could not create wallet'; status.className = 'status error'; @@ -231,7 +255,6 @@

Sign in to SimpleTip

btn.textContent = 'Continue'; }); - // Enter key for email document.getElementById('emailInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') document.getElementById('emailSubmit').click(); }); diff --git a/public/nav.js b/public/nav.js new file mode 100644 index 0000000..fbb3878 --- /dev/null +++ b/public/nav.js @@ -0,0 +1,128 @@ +/** + * SimpleTip shared navigation bar. + * Include via at the top of . + * Injects a header bar with nav links + login/profile icon. + */ +(function () { + 'use strict'; + + const API = '/simpletip/api'; + + function getWallet() { + try { + const raw = localStorage.getItem('simpletip_wallet'); + return raw ? JSON.parse(raw) : null; + } catch (e) { return null; } + } + + function cents(v) { return '$' + (v / 100).toFixed(2); } + + // Determine current page for active state + const path = window.location.pathname; + const page = path.split('/').pop() || 'index.html'; + + const links = [ + { href: 'index.html', label: 'Demo', match: ['index.html', ''] }, + { href: 'wallet.html', label: 'My Wallet', match: ['wallet.html'] }, + { href: 'setup.html', label: 'Receiver Setup', match: ['setup.html'] }, + { href: 'dashboard.html', label: 'Receiver Dashboard', match: ['dashboard.html'] }, + { href: 'profile.html', label: 'Profile', match: ['profile.html'] }, + ]; + + // Build nav HTML + const navLinksHtml = links.map(l => { + const active = l.match.includes(page) ? ' st-nav-active' : ''; + return `${l.label}`; + }).join(''); + + const w = getWallet(); + const isLoggedIn = w && w.token; + + let profileHtml; + if (isLoggedIn) { + const name = w.name || w.email || w.handle || ''; + const initial = (name[0] || '?').toUpperCase(); + const bal = typeof w.balance === 'number' ? cents(w.balance) : ''; + profileHtml = ` + + ${bal} + ${initial} + + `; + } else { + profileHtml = ` + + `; + } + + const bar = document.createElement('div'); + bar.className = 'st-navbar'; + bar.innerHTML = ` +
+ SimpleTip + +
${profileHtml}
+
+ `; + + // Inject CSS + const style = document.createElement('style'); + style.textContent = ` + .st-navbar { + background: #3f2534; color: #fff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + position: sticky; top: 0; z-index: 1000; box-shadow: 0 1px 4px rgba(0,0,0,0.15); + } + .st-navbar-inner { + max-width: 900px; margin: 0 auto; display: flex; align-items: center; + padding: 0 16px; height: 48px; gap: 8px; + } + .st-brand { + font-size: 1rem; font-weight: 700; color: #fff; text-decoration: none; + margin-right: 16px; white-space: nowrap; + } + .st-brand:hover { color: #00b2e5; } + .st-nav-links { display: flex; gap: 4px; flex: 1; flex-wrap: wrap; overflow: hidden; } + .st-nav-link { + color: rgba(255,255,255,0.7); text-decoration: none; font-size: 0.78rem; + padding: 4px 10px; border-radius: 4px; white-space: nowrap; + } + .st-nav-link:hover { color: #fff; background: rgba(255,255,255,0.1); } + .st-nav-link.st-nav-active { color: #fff; background: rgba(255,255,255,0.15); font-weight: 600; } + .st-nav-right { margin-left: auto; flex-shrink: 0; } + .st-login-link { + display: flex; align-items: center; gap: 6px; color: rgba(255,255,255,0.8); + text-decoration: none; font-size: 0.8rem; padding: 4px 10px; border-radius: 6px; + } + .st-login-link:hover { color: #fff; background: rgba(255,255,255,0.1); } + .st-login-link svg { width: 18px; height: 18px; } + .st-profile { + display: flex; align-items: center; gap: 8px; text-decoration: none; color: #fff; + padding: 3px 6px 3px 10px; border-radius: 6px; + } + .st-profile:hover { background: rgba(255,255,255,0.1); } + .st-profile-bal { font-size: 0.78rem; font-weight: 600; color: #22c55e; } + .st-profile-avatar { + width: 28px; height: 28px; border-radius: 50%; background: #00b2e5; color: #fff; + display: flex; align-items: center; justify-content: center; font-size: 0.82rem; font-weight: 700; + } + @media (max-width: 600px) { + .st-nav-links { display: none; } + .st-navbar-inner { height: 44px; } + } + `; + document.head.appendChild(style); + + // Insert at top of body + document.body.insertBefore(bar, document.body.firstChild); + + // Listen for wallet updates (from fund popup, login popup, etc.) + window.addEventListener('message', (event) => { + if (event.data && (event.data.type === 'simpletip-wallet-updated' || event.data.type === 'simpletip-auth')) { + localStorage.setItem('simpletip_wallet', JSON.stringify(event.data.wallet)); + location.reload(); // Refresh nav state + } + }); +})(); diff --git a/public/profile.html b/public/profile.html new file mode 100644 index 0000000..72e4191 --- /dev/null +++ b/public/profile.html @@ -0,0 +1,233 @@ + + + + + +Profile — SimpleTip + + + + +
+

Profile

+

Your contact info and connected accounts.

+ + + + +
+ + + + diff --git a/public/setup.html b/public/setup.html index a26e2b5..7c25cd3 100644 --- a/public/setup.html +++ b/public/setup.html @@ -3,30 +3,387 @@ -Author Setup — SimpleTip +Receiver Setup — SimpleTip +
-

Get SimpleTip for your content

-

Set up in 30 seconds. Get an embed code for your blog.

+

Set up SimpleTip for your content

+

Register as a tip receiver, choose how to get paid, get your embed code.

+ +
+
+
+
+
+ + +
+
+

Step 1: Your info

+

This is how donors will see you.

+ + + + + + + + + + + + + +
+ +
+
+ + +
+
+

Step 2: How do you want to get paid?

+

Choose your preferred payout method. You can add more later.

+

Non-Stripe-Connect payouts are limited to $600 per receiver per calendar year. Stripe Connect support coming soon for higher volumes.

+ +
+
+
💳
+
PayPal
+
+
+
💲
+
Venmo
+
+
+
+
Zelle
+
+
+
🏦
+
Bank (ACH)
+
+
+
📱
+
M-Pesa
+
+
+
💵
+
MoneyGram
+
+
+
🪙
+
USDT (ETH)
+
+
+
🪙
+
Tether (other)
+
+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + + + + + + + + + +
+
+ + + + +
+
+ + + + + + + + +
+
+ + +
+
+ + + + +
+ +
+ + +
+
+ + +
+
+
+

✓ You're all set!

+

Your slug:

+
+ +

Your embed code

+

Copy this and paste it into your blog, newsletter, or website:

+ +
+ +
- +

+ Works on Ghost, WordPress, Substack, Hugo, Jekyll — anywhere JavaScript runs.
+ The script tag only needs to be included once per page. +

- + View your dashboard +
+
- + + diff --git a/public/simpletip.js b/public/simpletip.js index 28a4493..79e040c 100644 --- a/public/simpletip.js +++ b/public/simpletip.js @@ -1,7 +1,12 @@ /** * SimpleTip — embeddable tipping web component * - * + * Usage: + * + * + * Split tips: + * * * Three states: * 1. Logged in + has balance → Tip buttons (one click, green flash) @@ -17,9 +22,7 @@ const BRAND = '#3f2534'; const ACCENT = '#00b2e5'; - const GREEN = '#22c55e'; const GREEN_DARK = '#16a34a'; - const PINK = '#ff6872'; const GOLD = '#f59e0b'; const BLUE_SKY = '#0085ff'; @@ -42,10 +45,6 @@ try { localStorage.setItem('simpletip_wallet', JSON.stringify(w)); } catch (e) {} } - function clearWallet() { - try { localStorage.removeItem('simpletip_wallet'); } catch (e) {} - } - // ── API helpers ─────────────────────────────────────────── async function apiPost(path, body) { @@ -56,27 +55,48 @@ return resp.json(); } - async function apiGet(path) { - const wallet = getWallet(); - const headers = {}; - if (wallet && wallet.token) headers['Authorization'] = `Bearer ${wallet.token}`; - const resp = await fetch(`${API}${path}`, { headers }); - return resp.json(); + // ── Widget load (register article + get receiver info) ──── + + async function widgetLoad(pageUrl, pageTitle, receiverSlugs) { + try { + return await apiPost('/widget/load', { + page_url: pageUrl, + page_title: pageTitle || document.title, + site_name: document.location.hostname, + receivers: receiverSlugs, + }); + } catch (e) { + console.error('SimpleTip: widget load failed', e); + return null; + } } // ── ────────────────────────────────────────── class SimpleTip extends HTMLElement { - connectedCallback() { - const author = this.getAttribute('author') || ''; - const authorName = this.getAttribute('author-name') || author; - const authorImg = this.getAttribute('author-img') || ''; + async connectedCallback() { + // Support both old (author/subject) and new (receiver/subject) attributes + const receiver = this.getAttribute('receiver') || this.getAttribute('author') || ''; + const receiverName = this.getAttribute('receiver-name') || this.getAttribute('author-name') || receiver; + const receiverImg = this.getAttribute('receiver-img') || this.getAttribute('author-img') || ''; const subject = this.getAttribute('subject') || ''; const subjectName = this.getAttribute('subject-label') || subject; - const subjectImg = this.getAttribute('subject-img') || ''; const defaultAmounts = (this.getAttribute('amounts') || '1,3,5').split(',').map(Number); + const demoAnonymous = this.hasAttribute('demo-anonymous'); const isSplit = !!subject; + // Build receiver list for API calls + const receiverSlugs = [receiver]; + if (subject) receiverSlugs.push(subject); + + // Register article + record impression + const loadResult = await widgetLoad(window.location.href, document.title, receiverSlugs); + if (!loadResult || loadResult.detail || loadResult.error) { + // Receiver not found or no payout method — don't render + console.warn('SimpleTip: not rendering —', loadResult?.detail || loadResult?.error || 'load failed'); + return; + } + const shadow = this.attachShadow({ mode: 'open' }); shadow.innerHTML = ` @@ -114,16 +134,18 @@ .amt:hover { background: #0090c0; transform: scale(1.05); } .amt:active { transform: scale(0.95); } .amt:disabled { opacity: 0.5; cursor: default; transform: none; } - /* Pledge style — slightly different look */ .amt.pledge-mode { background: rgba(255,255,255,0.2); border: 1px solid rgba(255,255,255,0.4); } .amt.pledge-mode:hover { background: rgba(255,255,255,0.3); } + .amt.topup-mode { background: ${GOLD}; color: #fff; } + .amt.topup-mode:hover { background: #d97706; } - .success-msg { - display: none; font-size: 0.82rem; font-weight: 600; - align-items: center; gap: 6px; - } + .success-msg { display: none; font-size: 0.82rem; font-weight: 600; align-items: center; gap: 6px; } .success-msg.show { display: flex; } .amounts.hide { display: none; } + .support-link { cursor: pointer; opacity: 0.85; text-decoration: underline; text-decoration-style: dotted; } + .support-link:hover { opacity: 1; } + .tip-again { background: rgba(255,255,255,0.2); color: #fff; border: 1px solid rgba(255,255,255,0.4); border-radius: 6px; padding: 4px 10px; font-size: 0.72rem; cursor: pointer; margin-left: 6px; } + .tip-again:hover { background: rgba(255,255,255,0.3); } .split-row { display: flex; align-items: center; gap: 8px; @@ -134,12 +156,17 @@ .split-pct { font-weight: 600; min-width: 28px; text-align: center; font-size: 0.72rem; } .split-label { font-size: 0.68rem; max-width: 80px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .footer { - display: flex; align-items: center; gap: 6px; font-size: 0.6rem; - opacity: 0.35; padding: 3px 16px 0; justify-content: flex-end; + .comment-row { + display: none; padding: 8px 16px; background: rgba(63,37,52,0.03); + } + .comment-row.show { display: block; } + .comment-row input { + width: 100%; padding: 6px 10px; border: 1px solid #ddd; border-radius: 6px; + font-size: 0.78rem; background: #fff; } + + .footer { display: flex; align-items: center; gap: 6px; font-size: 0.6rem; opacity: 0.35; padding: 3px 16px 0; justify-content: flex-end; } .footer a { color: inherit; text-decoration: none; } - .footer a:hover { opacity: 0.7; } .wallet-hint { display: none; font-size: 0.72rem; padding: 8px 16px; @@ -152,17 +179,16 @@ .bal-badge { display: none; font-size: 0.68rem; font-weight: 600; background: rgba(255,255,255,0.15); padding: 3px 8px; - border-radius: 4px; white-space: nowrap; flex-shrink: 0; - cursor: pointer; + border-radius: 4px; white-space: nowrap; flex-shrink: 0; cursor: pointer; } .bal-badge.show { display: block; } .bal-badge:hover { background: rgba(255,255,255,0.25); }
- ${authorImg ? `${esc(authorName)}` : ''} + ${receiverImg ? `${esc(receiverName)}` : ''}
-
Tip ${esc(authorName)}${isSplit ? ' + ' + esc(subjectName) : ''}
+
Tip ${esc(receiverName)}${isSplit ? ' + ' + esc(subjectName) : ''}
powered by SimpleTip
@@ -170,22 +196,28 @@ ${defaultAmounts.map(a => ``).join('')}
- Sent! + + Thank you! + Your support matters. +
${isSplit ? `
- ${esc(authorName)} - 50% + ${esc(receiverName)} + 50% 50% ${esc(subjectName)}
` : ''} +
+ +
Add funds to your wallet to tip with one click.
`; @@ -198,51 +230,118 @@ const whoLabel = shadow.getElementById('whoLabel'); const subLabel = shadow.getElementById('subLabel'); const slider = shadow.getElementById('slider'); - const authorPct = shadow.getElementById('authorPct'); - const subjectPct = shadow.getElementById('subjectPct'); + const receiverPctEl = shadow.getElementById('receiverPct'); + const subjectPctEl = shadow.getElementById('subjectPct'); const addFundsLink = shadow.getElementById('addFundsLink'); const balBadge = shadow.getElementById('balBadge'); + const commentRow = shadow.getElementById('commentRow'); + const commentInput = shadow.getElementById('commentInput'); + const tipAgainBtn = shadow.getElementById('tipAgainBtn'); + const supportLink = shadow.getElementById('supportLink'); const allBtns = shadow.querySelectorAll('.amt'); + let hasTipped = false; + let totalTippedCents = 0; // all-time total from DB - // ── State management ────────────────────────────────── + const _getWallet = () => demoAnonymous ? null : getWallet(); const _updateState = () => { - const w = getWallet(); - const hasFunds = w && w.token && w.balance > 0; + // After tipping, stay in thank-you state + if (hasTipped) { + bar.classList.add('success'); + bar.classList.remove('needs-funds'); + amountsDiv.classList.add('hide'); + successMsg.classList.add('show'); + successText.textContent = 'Thank you!'; + supportLink.textContent = 'Your support matters.'; + supportLink.style.display = ''; + tipAgainBtn.style.display = ''; + commentRow.classList.remove('show'); + // Still update balance badge + const w = _getWallet(); + if (w && w.token) { + const bal = (typeof w.balance === 'number' ? w.balance : 0) / 100; + balBadge.textContent = `$${bal.toFixed(2)}`; + balBadge.classList.add('show'); + } + return; + } + const w = _getWallet(); + const balCents = (w && typeof w.balance === 'number') ? w.balance : 0; const isLoggedIn = w && w.token; + const minAmountCents = Math.min(...defaultAmounts) * 100; + const canTipAny = isLoggedIn && balCents >= minAmountCents; - // Balance badge if (isLoggedIn) { - balBadge.textContent = `$${(w.balance || 0).toFixed(2)}`; + const bal = balCents / 100; + balBadge.textContent = `$${bal.toFixed(2)}`; balBadge.classList.add('show'); } else { balBadge.classList.remove('show'); } - // Button mode - if (hasFunds) { - // State 1: Tip mode - whoLabel.textContent = `Tip ${authorName}${isSplit ? ' + ' + subjectName : ''}`; + if (canTipAny) { + whoLabel.textContent = `Tip ${receiverName}${isSplit ? ' + ' + subjectName : ''}`; subLabel.textContent = 'powered by SimpleTip'; - allBtns.forEach(b => b.classList.remove('pledge-mode')); - } else if (isLoggedIn) { - // State 2: Pledge mode (logged in, no balance) - whoLabel.textContent = `Pledge to ${authorName}${isSplit ? ' + ' + subjectName : ''}`; - subLabel.textContent = 'fund your wallet later to send'; - allBtns.forEach(b => b.classList.add('pledge-mode')); + // Per-button: if balance covers it → normal tip, otherwise → top up + allBtns.forEach(b => { + const amt = parseFloat(b.dataset.amount) * 100; + b.classList.remove('pledge-mode', 'topup-mode'); + if (amt > balCents) { + b.classList.add('topup-mode'); + b.title = 'Top up to tip this amount'; + } else { + b.title = ''; + } + }); + commentRow.classList.add('show'); + } else if (isLoggedIn && balCents > 0) { + // Has some balance but not enough for even the smallest tip + whoLabel.textContent = `Tip ${receiverName}${isSplit ? ' + ' + subjectName : ''}`; + subLabel.textContent = 'top up your wallet to tip'; + bar.classList.add('needs-funds'); + allBtns.forEach(b => { b.classList.remove('pledge-mode'); b.classList.add('topup-mode'); b.title = 'Top up to tip'; }); + commentRow.classList.add('show'); } else { - // State 3: Not logged in — pledge mode, login on click - whoLabel.textContent = `Pledge to ${authorName}${isSplit ? ' + ' + subjectName : ''}`; - subLabel.textContent = 'sign in to track your pledges'; - allBtns.forEach(b => b.classList.add('pledge-mode')); + // No funds or no wallet — show tip label, clicking opens fund popup + whoLabel.textContent = `Tip ${receiverName}${isSplit ? ' + ' + subjectName : ''}`; + subLabel.textContent = isLoggedIn ? 'add funds to tip' : 'powered by SimpleTip'; + bar.classList.remove('needs-funds'); + allBtns.forEach(b => { b.classList.remove('pledge-mode', 'topup-mode'); b.title = ''; }); + commentRow.classList.remove('show'); } }; _updateState(); - // Clicking balance opens fund page balBadge.addEventListener('click', () => this._openFundingPopup()); - // Listen for auth/wallet updates from popups + tipAgainBtn.addEventListener('click', () => { + hasTipped = false; + bar.classList.remove('success'); + amountsDiv.classList.remove('hide'); + successMsg.classList.remove('show'); + tipAgainBtn.style.display = 'none'; + supportLink.style.display = ''; + allBtns.forEach(b => b.disabled = false); + _updateState(); + }); + + supportLink.addEventListener('click', async () => { + // Fetch all-time total from DB + const w = getWallet(); + if (w && w.token) { + try { + const headers = { 'Authorization': `Bearer ${w.token}` }; + const resp = await fetch(`${API}/wallet`, { headers }); + const data = await resp.json(); + if (data.totalTipped != null) { + totalTippedCents = data.totalTipped; + } + } catch (e) {} + } + const totalStr = (totalTippedCents / 100).toFixed(2); + supportLink.textContent = `You've given $${totalStr} total`; + }); + window.addEventListener('message', (event) => { if (event.data && (event.data.type === 'simpletip-wallet-updated' || event.data.type === 'simpletip-auth')) { saveWallet(event.data.wallet); @@ -250,89 +349,73 @@ } }); - // Update after tips - this.addEventListener('tip', () => setTimeout(_updateState, 100)); - this.addEventListener('pledge', () => setTimeout(_updateState, 100)); - - // Slider if (slider) { slider.addEventListener('input', () => { const v = parseInt(slider.value); - authorPct.textContent = v + '%'; - subjectPct.textContent = (100 - v) + '%'; + receiverPctEl.textContent = v + '%'; + subjectPctEl.textContent = (100 - v) + '%'; }); } - // Add funds link if (addFundsLink) { - addFundsLink.addEventListener('click', (e) => { - e.preventDefault(); - this._openFundingPopup(); - }); + addFundsLink.addEventListener('click', (e) => { e.preventDefault(); this._openFundingPopup(); }); } - // Amount buttons — behavior depends on state + // Build receivers array for API + const buildReceivers = () => { + const splitPct = slider ? parseInt(slider.value) : 100; + const receivers = [{ slug: receiver, pct: splitPct, role: 'author' }]; + if (subject) { + receivers.push({ slug: subject, pct: 100 - splitPct, role: 'subject' }); + } + return receivers; + }; + allBtns.forEach(btn => { btn.addEventListener('click', () => { const amount = parseFloat(btn.dataset.amount); - const splitPct = slider ? parseInt(slider.value) : 100; - const wallet = getWallet(); + const wallet = _getWallet(); + const amountCents = Math.round(amount * 100); + const comment = commentInput ? commentInput.value.trim() : ''; + const receivers = buildReceivers(); + + const setTipped = (totalFromDb) => { hasTipped = true; if (totalFromDb != null) totalTippedCents = totalFromDb; }; + const ctx = { receivers, amount, comment, bar, amountsDiv, successMsg, successText, walletHint, hintText, allBtns, btn, _updateState, setTipped }; - if (wallet && wallet.token && wallet.balance >= amount) { - // State 1: Tip from balance - this._handleTip(btn, { - author, authorName, subject, subjectName, amount, splitPct, - bar, amountsDiv, successMsg, successText, walletHint, hintText, allBtns, - _updateState, - }); - } else if (wallet && wallet.token) { - // State 2: Pledge (logged in, insufficient funds) - this._handlePledge(btn, { - author, authorName, subject, subjectName, amount, splitPct, - bar, amountsDiv, successMsg, successText, walletHint, hintText, allBtns, - _updateState, - }); + if (wallet && wallet.token && wallet.balance >= amountCents) { + this._handleTip(ctx); } else { - // State 3: Not logged in — open login, then pledge - this._handleLoginThenPledge(btn, { - author, authorName, subject, subjectName, amount, splitPct, - bar, amountsDiv, successMsg, successText, walletHint, hintText, allBtns, - _updateState, - }); + // No wallet, no funds, or not enough — auto-create wallet if needed, then open fund popup + this._handleAutoFund(ctx, amountCents); } }); }); } - // ── State 1: Tip from wallet balance ────────────────── - - async _handleTip(btn, ctx) { - const { author, subject, amount, splitPct, - bar, amountsDiv, successMsg, successText, walletHint, hintText, allBtns, - _updateState } = ctx; - + async _handleTip(ctx) { + const { receivers, amount, comment, allBtns, btn, _updateState, setTipped } = ctx; allBtns.forEach(b => b.disabled = true); btn.textContent = '...'; try { const result = await apiPost('/tip', { - author, - subject: subject || undefined, + receivers, amount, - splitPct: subject ? splitPct : undefined, + comment: comment || undefined, + page_url: window.location.href, }); if (result.success) { const wallet = getWallet(); if (wallet) { wallet.balance = result.balance; saveWallet(wallet); } - this._showFlash(bar, amountsDiv, successMsg, successText, `$${amount} sent!`, 'success', allBtns, btn, amount, _updateState); + setTipped(result.totalTipped); + _updateState(); this.dispatchEvent(new CustomEvent('tip', { bubbles: true, detail: { amount } })); return; } - if (result.error === 'insufficient_funds') { - // Switch to pledge - this._handlePledge(btn, ctx); + if (result.detail && result.detail.includes('insufficient_funds')) { + this._handlePledge(ctx); return; } } catch (err) { @@ -342,34 +425,29 @@ btn.textContent = `$${amount}`; } - // ── State 2: Pledge (logged in, no/insufficient balance) ── - - async _handlePledge(btn, ctx) { - const { author, subject, amount, splitPct, - bar, amountsDiv, successMsg, successText, walletHint, hintText, allBtns, - _updateState } = ctx; - + async _handlePledge(ctx) { + const { receivers, amount, comment, bar, amountsDiv, successMsg, successText, walletHint, hintText, allBtns, btn, _updateState } = ctx; allBtns.forEach(b => b.disabled = true); btn.textContent = '...'; try { const result = await apiPost('/pledge', { - author, - subject: subject || undefined, + receivers, amount, - splitPct: subject ? splitPct : undefined, + comment: comment || undefined, + page_url: window.location.href, }); if (result.success) { - const msg = result.pendingTotal > amount - ? `Pledged $${amount}! ($${result.pendingTotal.toFixed(2)} total)` + const totalDollars = (result.pendingTotal / 100).toFixed(2); + const msg = result.pendingTotal > Math.round(amount * 100) + ? `Pledged $${amount}! ($${totalDollars} total)` : `Pledged $${amount}!`; this._showFlash(bar, amountsDiv, successMsg, successText, msg, 'pledged', allBtns, btn, amount, _updateState); - // Show fund prompt if pledges are piling up - if (result.pendingTotal >= 5) { + if (result.pendingTotal >= 500) { setTimeout(() => { - hintText.innerHTML = `You've pledged $${result.pendingTotal.toFixed(2)}. Fund your wallet to send it!`; + hintText.innerHTML = `You've pledged $${totalDollars}. Fund your wallet to send it!`; walletHint.classList.add('show'); const fundLink = hintText.querySelector('#fundNowLink'); if (fundLink) fundLink.addEventListener('click', (e) => { e.preventDefault(); this._openFundingPopup(); }); @@ -386,31 +464,45 @@ btn.textContent = `$${amount}`; } - // ── State 3: Not logged in — login popup, then pledge ── + async _handleAutoFund(ctx, amountCents) { + const { allBtns, btn, amount, _updateState } = ctx; - _handleLoginThenPledge(btn, ctx) { - const { amount, allBtns, _updateState } = ctx; + // Auto-create wallet if none exists (cookie-style, no signup needed) + let wallet = getWallet(); + if (!wallet || !wallet.token) { + btn.textContent = '...'; + try { + const result = await apiPost('/wallet/create', {}); + if (result.token) { + wallet = { token: result.token, balance: result.balance || 0, name: result.name || '', email: result.email || null }; + saveWallet(wallet); + } + } catch (e) { + console.error('SimpleTip: wallet create failed', e); + btn.textContent = `$${amount}`; + return; + } + } - // Open login popup - const popup = window.open(`${BASE_URL}/login.html`, 'simpletip-login', - 'width=400,height=500,scrollbars=yes'); + // Open funding popup — when it returns with funds, auto-tip + const popup = window.open(`${BASE_URL}/fund.html`, 'simpletip-fund', 'width=420,height=550,scrollbars=yes'); - // Listen for auth completion const msgHandler = (event) => { - if (event.data && event.data.type === 'simpletip-auth') { + if (event.data && event.data.type === 'simpletip-wallet-updated') { window.removeEventListener('message', msgHandler); saveWallet(event.data.wallet); _updateState(); if (popup && !popup.closed) popup.close(); - // Now create the pledge - this._handlePledge(btn, ctx); + // If they now have enough, auto-send the tip + const w = getWallet(); + if (w && w.balance >= amountCents) { + this._handleTip(ctx); + } } }; window.addEventListener('message', msgHandler); } - // ── Flash animation (tip or pledge) ───────────────────── - _showFlash(bar, amountsDiv, successMsg, successText, text, cssClass, allBtns, btn, amount, _updateState) { bar.classList.add(cssClass); amountsDiv.classList.add('hide'); @@ -428,8 +520,7 @@ } _openFundingPopup() { - const w = window.open(`${BASE_URL}/fund.html`, 'simpletip-fund', - 'width=420,height=550,scrollbars=yes'); + const w = window.open(`${BASE_URL}/fund.html`, 'simpletip-fund', 'width=420,height=550,scrollbars=yes'); const msgHandler = (event) => { if (event.data && event.data.type === 'simpletip-wallet-updated') { @@ -442,114 +533,5 @@ } } - // ── — author registration widget ─────── - - class SimpleTipSetup extends HTMLElement { - connectedCallback() { - const shadow = this.attachShadow({ mode: 'open' }); - shadow.innerHTML = ` - - -
-

Set up tipping for your content

-

Get an embeddable tip widget for your blog, newsletter, or website. Readers tip you with one click.

- -
- - - - - - - - - - - - - -
- -
-

✓ You're set up!

-

Copy this code and paste it into your blog template, after each article, or in a custom HTML block:

-
-
-
- `; - - const registerBtn = shadow.getElementById('registerBtn'); - const formSection = shadow.getElementById('formSection'); - const result = shadow.getElementById('result'); - const embedCode = shadow.getElementById('embedCode'); - - registerBtn.addEventListener('click', async () => { - const name = shadow.getElementById('nameInput').value.trim(); - const email = shadow.getElementById('emailInput').value.trim(); - const method = shadow.getElementById('payoutMethod').value; - const addr = shadow.getElementById('payoutAddr').value.trim(); - - if (!name || !email) return; - - registerBtn.disabled = true; - registerBtn.textContent = 'Setting up...'; - - try { - const resp = await apiPost('/author/register', { name, email, payoutMethod: method, payoutAddress: addr }); - - if (resp.slug) { - embedCode.textContent = - ` + + + diff --git a/public/wallet.html b/public/wallet.html new file mode 100644 index 0000000..74293e3 --- /dev/null +++ b/public/wallet.html @@ -0,0 +1,248 @@ + + + + + +My Wallet — SimpleTip + + + + +
+

My Wallet

+

Your SimpleTip balance and tip history.

+ + + + + +
+ + + +