Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 49 additions & 6 deletions .github/workflows/sync-worker-cors.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ on:
- 'src/assistants/*/config.yaml'
- 'scripts/sync_worker_cors.py'
- '.github/workflows/sync-worker-cors.yml'
- 'workers/osa-worker/**'
pull_request:
branches: [main, develop]
paths:
- 'src/assistants/*/config.yaml'
- 'workers/osa-worker/**'

permissions:
contents: write
Expand Down Expand Up @@ -40,16 +42,50 @@ jobs:
- name: Check for changes
id: check_changes
run: |
# Check if CORS sync made changes
if git diff --quiet workers/osa-worker/index.js; then
echo "changed=false" >> $GITHUB_OUTPUT
echo "No CORS changes detected"
CORS_CHANGED=false
else
echo "changed=true" >> $GITHUB_OUTPUT
echo "CORS changes detected"
CORS_CHANGED=true
fi

# Check if worker files were modified in the push that triggered this workflow
# Use the range from the push event (before -> after)
if [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
# Normal push (not first commit)
if git diff --name-only ${{ github.event.before }} ${{ github.sha }} | grep -q "^workers/osa-worker/"; then
echo "Worker files changed in push"
WORKER_CHANGED=true
else
echo "No worker files in push"
WORKER_CHANGED=false
fi
else
# First commit to branch, check current files
if git ls-files workers/osa-worker/ | grep -q .; then
echo "Worker files exist (first commit)"
WORKER_CHANGED=true
else
WORKER_CHANGED=false
fi
fi

# Set outputs
echo "cors_changed=$CORS_CHANGED" >> $GITHUB_OUTPUT

# Deploy if either CORS sync changed files OR worker files were pushed
if [ "$CORS_CHANGED" = true ] || [ "$WORKER_CHANGED" = true ]; then
echo "changed=true" >> $GITHUB_OUTPUT
echo "✅ Deployment needed"
else
echo "changed=false" >> $GITHUB_OUTPUT
echo "No deployment needed"
fi

- name: Commit changes (main/develop only)
if: steps.check_changes.outputs.changed == 'true' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') && github.event_name == 'push'
- name: Commit CORS changes (main/develop only)
if: steps.check_changes.outputs.cors_changed == 'true' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') && github.event_name == 'push'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
Expand All @@ -64,7 +100,7 @@ jobs:
run: |
npm install -g wrangler
cd workers/osa-worker
wrangler deploy --env=""
wrangler deploy
echo "✅ Deployed to production worker"

- name: Deploy to Cloudflare Workers (dev)
Expand All @@ -82,9 +118,16 @@ jobs:
uses: actions/github-script@v7
with:
script: |
let message = '⚠️ **Worker Deployment Required**\n\n';
if ('${{ steps.check_changes.outputs.cors_changed }}' === 'true') {
message += 'This PR modifies community CORS origins. ';
}
message += 'Worker changes detected. After merging to `main` or `develop`, the workflow will automatically deploy the worker.\n\n';
message += '**Manual deployment (if needed):**\n```bash\ncd workers/osa-worker\nwrangler deploy --env dev # for develop branch\nwrangler deploy # for main branch\n```';

github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '⚠️ **Worker CORS Update Required**\n\nThis PR modifies community CORS origins. After merging, run:\n\n```bash\npython scripts/sync_worker_cors.py\ngit add workers/osa-worker/index.js\ngit commit -m "chore: sync worker CORS"\ncd workers/osa-worker && wrangler deploy\n```\n\nOr merge to main and the sync workflow will auto-commit the changes.'
body: message
})
46 changes: 24 additions & 22 deletions workers/osa-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

Security proxy for the Open Science Assistant backend. Provides:
- **Turnstile verification** (visible widget) for bot protection
- **Rate limiting** (IP-based, per-minute and per-hour)
- **Hybrid rate limiting** (IP-based, per-minute and per-hour)
- Per-minute: Built-in API (fast bot protection, <1ms)
- Per-hour: KV (global human abuse prevention)
- **CORS validation** for allowed origins
- **API key injection** for backend authentication
- **BYOK mode** for CLI/programmatic access
Expand Down Expand Up @@ -39,23 +41,11 @@ npm install -g wrangler
wrangler login
```

### 2. Create KV namespaces for rate limiting
### 2. KV namespaces

```bash
# Production
wrangler kv:namespace create "RATE_LIMITER"
# Copy the ID and update wrangler.toml

# Development
wrangler kv:namespace create "RATE_LIMITER" --env dev
# Copy the ID and update wrangler.toml [env.dev.kv_namespaces]
```
KV namespaces are already configured in `wrangler.toml` for per-hour rate limiting. No additional setup needed.

### 3. Update wrangler.toml

Replace `REPLACE_WITH_KV_ID` and `REPLACE_WITH_DEV_KV_ID` with the IDs from step 2.

### 4. Set up Turnstile
### 3. Set up Turnstile

1. Go to Cloudflare Dashboard > Turnstile
2. Create a new widget with **Visible** mode
Expand All @@ -67,7 +57,7 @@ Replace `REPLACE_WITH_KV_ID` and `REPLACE_WITH_DEV_KV_ID` with the IDs from step
4. Copy the Site Key (for frontend integration)
5. Copy the Secret Key (for this worker)

### 5. Set secrets
### 4. Set secrets

```bash
# Backend API key (generate with: python -c "import secrets; print(secrets.token_urlsafe(32))")
Expand All @@ -81,7 +71,7 @@ wrangler secret put BACKEND_API_KEY --env dev
wrangler secret put TURNSTILE_SECRET_KEY --env dev
```

### 6. Deploy
### 5. Deploy

```bash
# Production
Expand All @@ -104,10 +94,22 @@ wrangler deploy --env dev

## Rate Limits

| Environment | Per Minute | Per Hour |
|-------------|------------|----------|
| Production | 10 | 100 |
| Development | 60 | 600 |
Hybrid approach for optimal performance and protection:

| Environment | Per Minute (Bot Protection) | Per Hour (Human Abuse) |
|-------------|----------------------------|----------------------|
| Production | 10 (built-in API) | 20 (KV, global) |
| Development | 60 (built-in API) | 100 (KV, global) |

**Why hybrid?**
- **Per-minute**: Needs to be fast (<1ms), catches bots immediately → Built-in API
- **Per-hour**: Needs global consistency across edge locations → KV
- **Result**: 50% fewer KV writes (1 vs 2 per request), faster bot protection

**Rate limit scope:**
- Limits are **per IP address**, not per session
- 20/hour in production = ~1 question every 3 minutes (reasonable for research)
- Prevents abuse while allowing legitimate use

## BYOK Mode

Expand Down
76 changes: 55 additions & 21 deletions workers/osa-worker/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ function getConfig(env) {
const isDev = env.ENVIRONMENT === 'development';
return {
RATE_LIMIT_PER_MINUTE: isDev ? 60 : 10,
RATE_LIMIT_PER_HOUR: isDev ? 600 : 100,
RATE_LIMIT_PER_HOUR: isDev ? 100 : 20,
REQUEST_TIMEOUT: 120000, // 2 minutes for LLM responses
IS_DEV: isDev,
};
Expand Down Expand Up @@ -66,33 +66,67 @@ async function verifyTurnstileToken(token, secretKey, ip) {
}

/**
* Check rate limit using KV storage
* Hybrid rate limiting approach:
* - Per-minute (bot protection): Built-in API (fast, <1ms, in-memory)
* - Per-hour (human abuse): KV (global consistency, 1 write per request)
*
* Benefits:
* - 50% reduction in KV writes (1 vs 2 per request)
* - Faster bot protection (<1ms vs ~10-50ms for critical first check)
* - Global hourly limits across all edge locations
*
* Known limitation:
* - KV read-then-write is not atomic; concurrent requests from same IP
* may slightly exceed hourly limit. Per-minute guard constrains this.
*/
async function checkRateLimit(request, env, CONFIG) {
if (!env.RATE_LIMITER) return { allowed: true };

const ip = request.headers.get('CF-Connecting-IP') || 'unknown';
const now = Math.floor(Date.now() / 1000);
const minuteKey = `rl:min:${ip}:${Math.floor(now / 60)}`;
const hourKey = `rl:hour:${ip}:${Math.floor(now / 3600)}`;

// Check per-minute limit
const minuteCount = parseInt(await env.RATE_LIMITER.get(minuteKey) || '0');
if (minuteCount >= CONFIG.RATE_LIMIT_PER_MINUTE) {
return { allowed: false, reason: 'Too many requests per minute' };

// Check hourly limit first (KV, read-only, no token consumed)
// This prevents wasting per-minute tokens on already-rejected requests
if (env.RATE_LIMITER_KV) {
try {
const now = Math.floor(Date.now() / 1000);
const hourKey = `rl:hour:${ip}:${Math.floor(now / 3600)}`;

// Check current count
const hourCount = parseInt(await env.RATE_LIMITER_KV.get(hourKey) || '0', 10);
if (hourCount >= CONFIG.RATE_LIMIT_PER_HOUR) {
return { allowed: false, reason: 'Too many requests per hour' };
}
} catch (error) {
console.error('Per-hour rate limit check error:', error);
// Fail open for KV errors
}
}

// Check per-hour limit
const hourCount = parseInt(await env.RATE_LIMITER.get(hourKey) || '0');
if (hourCount >= CONFIG.RATE_LIMIT_PER_HOUR) {
return { allowed: false, reason: 'Too many requests per hour' };
// Check per-minute limit (built-in API, fast, consumes token)
// Only check this AFTER hourly passes to avoid wasting tokens
if (env.RATE_LIMITER_MINUTE) {
try {
const { success } = await env.RATE_LIMITER_MINUTE.limit({ key: ip });
if (!success) {
return { allowed: false, reason: 'Too many requests per minute' };
}
} catch (error) {
console.error('Per-minute rate limit check error:', error);
// Fail open for built-in API errors
}
}

// Increment counters
await Promise.all([
env.RATE_LIMITER.put(minuteKey, (minuteCount + 1).toString(), { expirationTtl: 120 }),
env.RATE_LIMITER.put(hourKey, (hourCount + 1).toString(), { expirationTtl: 7200 }),
]);
// Increment hourly counter (1 write per request instead of 2)
// Done last, after both checks pass
if (env.RATE_LIMITER_KV) {
try {
const now = Math.floor(Date.now() / 1000);
const hourKey = `rl:hour:${ip}:${Math.floor(now / 3600)}`;
const hourCount = parseInt(await env.RATE_LIMITER_KV.get(hourKey) || '0', 10);
await env.RATE_LIMITER_KV.put(hourKey, (hourCount + 1).toString(), { expirationTtl: 7200 });
} catch (error) {
console.error('Per-hour rate limit increment error:', error);
// Already allowed, so don't fail the request
}
}

return { allowed: true };
}
Expand Down
35 changes: 24 additions & 11 deletions workers/osa-worker/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,35 @@ compatibility_date = "2024-01-01"
BACKEND_URL = "https://api.osc.earth/osa"
ENVIRONMENT = "production"

# KV namespace for rate limiting
# Hybrid rate limiting approach:
# - Per-minute (bot protection): Built-in API (fast, in-memory)
# - Per-hour (human abuse): KV (global consistency)

# Per-minute rate limiter (built-in API, free, <1ms latency)
[[ratelimits]]
name = "RATE_LIMITER_MINUTE"
namespace_id = "1001"
simple = { limit = 10, period = 60 }

# Per-hour rate limiter (KV, global, 1 write per request)
[[kv_namespaces]]
binding = "RATE_LIMITER"
binding = "RATE_LIMITER_KV"
id = "8f8506b1a7fb400680a00014312c124d"

# Development environment
[env.dev]
name = "osa-worker-dev"
vars = { BACKEND_URL = "https://api.osc.earth/osa-dev", ENVIRONMENT = "development" }

# Per-minute rate limiter (dev has higher limit: 60/min)
[[env.dev.ratelimits]]
name = "RATE_LIMITER_MINUTE"
namespace_id = "1002"
simple = { limit = 60, period = 60 }

# Per-hour rate limiter (KV)
[[env.dev.kv_namespaces]]
binding = "RATE_LIMITER"
binding = "RATE_LIMITER_KV"
id = "6d46ef72877b4129b38ef2ca1e1cd5ea"

# =============================================================================
Expand All @@ -34,23 +51,19 @@ id = "6d46ef72877b4129b38ef2ca1e1cd5ea"
# 2. Login to Cloudflare:
# wrangler login
#
# 3. Create KV namespaces:
# wrangler kv:namespace create "RATE_LIMITER"
# wrangler kv:namespace create "RATE_LIMITER" --env dev
#
# 4. Update this file with the KV namespace IDs from step 3
# 3. KV namespaces already exist (IDs in this file)
#
# 5. Set secrets:
# 4. Set secrets:
# wrangler secret put BACKEND_API_KEY
# wrangler secret put TURNSTILE_SECRET_KEY
# wrangler secret put BACKEND_API_KEY --env dev
# wrangler secret put TURNSTILE_SECRET_KEY --env dev
#
# 6. Deploy:
# 5. Deploy:
# wrangler deploy # Production
# wrangler deploy --env dev # Development
#
# 7. Verify:
# 6. Verify:
# curl https://osa-worker.<your-subdomain>.workers.dev/health
#
# =============================================================================
Expand Down