Architecture
Overview
SlopDrop is a two-package monorepo:
- CLI (
packages/cli) — Go binary, handles local file operations and API communication - API (
packages/api) — Cloudflare Worker, handles auth, storage, rendering, and admin
Stack
| Component | Technology | Why |
|---|---|---|
| CLI | Go | Single binary, cross-platform, fast |
| API | Cloudflare Workers (Hono) | Edge deployment, low latency, no servers |
| Metadata | D1 (SQLite) | Structured queries, tenant isolation |
| File Storage | R2 | S3-compatible, no egress fees |
| Rate Limiting | D1 | 100k writes/day vs KV's 1k |
| Bot Protection | Cloudflare Turnstile | Contact form captcha |
| Access Control | Cloudflare Access + WAF | Per-tenant auth, subdomain allowlist |
Data Model
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Tenant │────▶│ Project │────▶│ Drop │
│ │ │ (implicit) │ │ (versioned)│
│ slug │ │ │ │ path │
│ plan │ │ Derived from│ │ version │
│ is_public │ │ drop paths │ │ content→R2 │
│ api_keys[] │ │ │ │ │
└─────────────┘ └─────────────┘ └─────────────┘
Projects are implicit — they're derived from the first path segment of drops. No project table in the database.
Tenants are private by default — is_public must be explicitly set to serve content without Cloudflare Access authentication.
Request Flow
Upload (CLI → API → Storage)
1. CLI reads file, computes hash
2. CLI sends POST /api/v1/drops (multipart)
3. API validates: auth → path → secrets → size
4. API stores: content → R2, metadata → D1
5. API returns: version number, URL
Render (Browser → WAF → Access → Worker → HTML)
1. Browser hits {tenant}.slopdrop.dev/{project}/{path}
2. WAF checks subdomain against allowlist → blocks unknown subdomains
3. Cloudflare Access checks auth (per-tenant policy, if app exists)
4. Worker resolves tenant from subdomain via D1 lookup
5. If tenant is private: verify CF Access JWT signature → redirect if invalid
6. Worker fetches content from R2
7. Markdown rendered to HTML with:
- Table of contents (h2/h3) with scroll spy
- Wiki-link resolution ([[target]])
- GitHub-style callouts (NOTE, TIP, WARNING, CAUTION, IMPORTANT)
- GFM tables with alignment
- Task lists with checkboxes
8. HTML returned with 60s cache
Admin (admin.slopdrop.dev)
1. Request hits admin.slopdrop.dev
2. CF Access authenticates user (Google OAuth / email)
3. Worker verifies CF Access JWT
4. Server-rendered HTML dashboard:
- Overview: tenant count, drops, storage, API calls
- Tenants: list, create, toggle public/private
- Usage: per-tenant monthly metrics
- Contacts: form submissions from landing page
Security Model — Three Layers
Layer 1: WAF (Phase 14 — before Worker)
WAF custom rule blocks all *.slopdrop.dev subdomains except an explicit allowlist. Blocked requests never reach the Worker — zero billing, zero exposure.
Layer 2: Cloudflare Access (Phase 19 — before Worker)
Per-tenant Access applications control who can authenticate. Supports Google OAuth, email domains, specific email addresses. Issues a signed JWT on successful auth.
Layer 3: Worker (JWT Verification)
For private tenants (is_public = 0), the Worker verifies the CF-Access-Jwt-Assertion header:
- Validates JWT signature against Cloudflare's published public keys (1-hour cache)
- Checks token expiry
- Returns bare 301 redirect (matching WAF response) if invalid — no fingerprinting
- Rate limits failed attempts: 20/min per IP
Why all three? WAF handles cost (no billing for junk). Access handles auth UX (login flow). Worker handles the gap: tenant exists in D1 but has no Access app yet.
Additional Security
- Tenant isolation — TenantDb wrapper injects tenant_id into every query
- API keys — HMAC-SHA256 with pepper, never stored in plaintext
- Secret scanning — Regex patterns catch API keys, tokens, passwords pre-upload
- Path validation — No traversal attacks, no
@or special characters in paths - Turnstile — Bot protection on contact form
Versioning
Every upload to the same path increments the version. R2 stores each version at {tenant_id}/{project}/{path}@v{N}. D1 tracks version number, timestamps, content hash, and file size.
The web renderer always serves the latest version. Version history is available via the API but not yet exposed in the rendered view.
Cloudflare Platform Limits
| Resource | Free | Paid ($5/mo) |
|---|---|---|
| Worker requests | 100k/day | 10M/month |
| D1 reads | 5M/day | 25B/month |
| D1 writes | 100k/day | 50M/month |
| R2 storage | 10 GB | $0.015/GB |
| R2 reads | 10M/month | $0.36/M |
| R2 egress | Free | Free |
We also enforce a WAF rate limit of 50 requests/10 seconds per IP to catch runaway clients before they burn through the daily quota.