Skip to content
Draft
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
15 changes: 13 additions & 2 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,29 @@
.git
.vscode
.idea
.claude

# external dependencies
node_modules
**/node_modules

# docker files
docker-compose*.yml
**/Dockerfile*

# build artifacts
dist/
**/dist
coverage/
**/coverage

# not needed files
README.md
tools/
!tools/deployment/nginx
.gitignore
.env
coverage/

# env files hold secrets — never in a build context
**/.env
**/.env.*
!**/.env.example
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Three onboarding paths (A, B local-run; C docs-only). README "Get started" is th
| `pnpm preflight` | both | Verify Node / pnpm / Docker / ports / `.env` files. Add `--json` for agents |
| `pnpm dev` / `pnpm dev:demo` | A | Demo (UI only, port 4200). No backend, no Docker |
| `pnpm infra:up` | B | Start Postgres + Temporal in Docker. Required before backend/worker |
| `pnpm -F backend db:migrate` | B | Apply Drizzle migrations. First run, or after schema changes |
| `pnpm -F backend db:migrate` | B | Apply Drizzle migrations out-of-band (backend also auto-migrates on boot) |
| `pnpm dev:ai-studio` | B | Full stack: infra + backend (3001) + worker + AI Studio frontend (4201) |
| `pnpm dev:backend` | B | Backend only (debug). Needs infra up |
| `pnpm dev:worker` | B | Execution worker only (debug). Needs infra up |
Expand All @@ -22,7 +22,7 @@ Three onboarding paths (A, B local-run; C docs-only). README "Get started" is th
| `pnpm test` | - | Run tests in `packages/sdk` and `packages/execution-core` |
| `pnpm check` | - | Lint + typecheck + format + knip |

Path A is UI-only and does not need Docker. Path B requires `pnpm infra:up` before backend/worker can start, and `db:migrate` on the first run.
Path A is UI-only and does not need Docker. Path B requires `pnpm infra:up` before backend/worker can start; the backend applies pending migrations automatically at boot.

### Agent signals

Expand All @@ -42,6 +42,9 @@ Long-running processes already emit stable log lines that scripts and agents can

```
tools/ - Root dev scripts: preflight, setup:env, infra wait
deployment/ - Swarm/Ansible deploy path mirroring the workflow-builder repo (ACR, Traefik)
deploy/
ai-studio/ - Production deployment: Dockerfile (runtime/web), compose, nginx, README
apps/
demo/ - Reference app consuming the SDK (React + Vite, port 4200)
ai-studio/ - Reference AI workflow product (React + Vite, port 4201)
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"dev": "tsx watch --env-file=.env ./src/server.ts",
"start": "tsx --env-file=.env ./src/server.ts",
"start:prod": "tsx ./src/server.ts",
"typecheck": "tsc --noEmit",
"lint": "eslint",
"lint:fix": "eslint --fix",
Expand All @@ -24,6 +25,7 @@
"drizzle-orm": "^0.44.0",
"hono": "^4.7.0",
"postgres": "^3.4.5",
"tsx": "^4.19.3",
"zod": "^4.3.6"
},
"devDependencies": {
Expand Down
18 changes: 18 additions & 0 deletions apps/backend/src/db/migrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import { fileURLToPath } from 'node:url';
import postgres from 'postgres';

import { env } from '../env';

// Same SQL files as `pnpm db:migrate`. Concurrent backends would race the
// migrator — single replica assumed.
export async function runMigrations(): Promise<void> {
const migrationsFolder = fileURLToPath(new URL('../../drizzle', import.meta.url));
const sql = postgres(env.DATABASE_URL, { max: 1 });
try {
await migrate(drizzle(sql), { migrationsFolder });
} finally {
await sql.end();
}
}
4 changes: 4 additions & 0 deletions apps/backend/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,8 @@ export const env = {
HOST: envOr('HOST', '127.0.0.1'),
DATABASE_URL: envOr('DATABASE_URL', 'postgresql://wb:wb@127.0.0.1:5432/workflow_builder'),
TEMPORAL_ADDRESS: envOr('TEMPORAL_ADDRESS', '127.0.0.1:7233'),
// 0 disables (dev default); the deploy compose sets both
RATE_LIMIT_EXECUTE_PER_MINUTE: Number(envOr('RATE_LIMIT_EXECUTE_PER_MINUTE', '0')),
RATE_LIMIT_EXECUTE_PER_DAY: Number(envOr('RATE_LIMIT_EXECUTE_PER_DAY', '0')),
TRUST_PROXY: envOr('TRUST_PROXY', 'false') === 'true',
};
128 changes: 128 additions & 0 deletions apps/backend/src/middleware/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { Hono } from 'hono';
import { describe, expect, it } from 'vitest';

import { type RateLimitOptions, createRateLimitMiddleware } from './rate-limit';

const MINUTE_MS = 60_000;
const DAY_MS = 24 * 60 * 60 * 1000;

function makeApp(overrides: Partial<RateLimitOptions> = {}) {
let timestamp = 0;
const app = new Hono();
app.use(
'/api/workflows/:id/execute',
createRateLimitMiddleware({
perMinute: 2,
perDay: 5,
trustProxy: true,
now: () => timestamp,
...overrides,
}),
);
app.post('/api/workflows/:id/execute', (c) => c.json({ ok: true }, 202));

return {
app,
advance(ms: number) {
timestamp += ms;
},
execute(ip = '203.0.113.7') {
return app.request('/api/workflows/wf-1/execute', {
method: 'POST',
headers: { 'x-forwarded-for': ip },
});
},
};
}

describe('createRateLimitMiddleware', () => {
it('allows requests under the limit', async () => {
const { execute } = makeApp();

const first = await execute();
const second = await execute();
expect(first.status).toBe(202);
expect(second.status).toBe(202);
});

it('rejects with 429 and Retry-After once the minute limit is hit', async () => {
const { execute, advance } = makeApp();

await execute();
await execute();
advance(10_000);

const response = await execute();
expect(response.status).toBe(429);
expect(response.headers.get('Retry-After')).toBe('50');
expect(await response.json()).toMatchObject({ code: 'rate_limited', retryAfterSeconds: 50 });
});

it('tracks each IP independently', async () => {
const { execute } = makeApp();

await execute('203.0.113.7');
await execute('203.0.113.7');
const blocked = await execute('203.0.113.7');
const otherIp = await execute('198.51.100.9');
expect(blocked.status).toBe(429);
expect(otherIp.status).toBe(202);
});

it('resets the minute window after it elapses', async () => {
const { execute, advance } = makeApp();

await execute();
await execute();
const blocked = await execute();
expect(blocked.status).toBe(429);

advance(MINUTE_MS);
const allowedAgain = await execute();
expect(allowedAgain.status).toBe(202);
});

it('enforces the day limit across minute windows', async () => {
const { execute, advance } = makeApp();

for (let index = 0; index < 5; index++) {
const allowed = await execute();
expect(allowed.status).toBe(202);
advance(MINUTE_MS);
}

const response = await execute();
expect(response.status).toBe(429);
// 5 minutes into the day window -> retry once the remaining day elapses
expect(response.headers.get('Retry-After')).toBe(String((DAY_MS - 5 * MINUTE_MS) / 1000));
});

it('resets the day window after it elapses', async () => {
const { execute, advance } = makeApp({ perMinute: 0 });

for (let index = 0; index < 5; index++) {
await execute();
}
const blocked = await execute();
expect(blocked.status).toBe(429);

advance(DAY_MS);
const allowedAgain = await execute();
expect(allowedAgain.status).toBe(202);
});

it('uses the first X-Forwarded-For hop as the client identity', async () => {
const { app } = makeApp();

const request = (chain: string) =>
app.request('/api/workflows/wf-1/execute', {
method: 'POST',
headers: { 'x-forwarded-for': chain },
});

await request('203.0.113.7, 10.0.0.1');
await request('203.0.113.7, 10.0.0.2');
const blocked = await request('203.0.113.7, 10.0.0.3');
expect(blocked.status).toBe(429);
});
});
110 changes: 110 additions & 0 deletions apps/backend/src/middleware/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { getConnInfo } from '@hono/node-server/conninfo';
import type { Context, MiddlewareHandler } from 'hono';

export type RateLimitOptions = {
// 0 disables a window
perMinute: number;
perDay: number;
// only safe when the backend is reachable exclusively through a proxy that
// sets X-Forwarded-For — a directly reachable backend lets clients spoof it
trustProxy: boolean;
now?: () => number;
};

type WindowState = {
windowStart: number;
count: number;
};

type IpState = {
minute: WindowState;
day: WindowState;
};

const MINUTE_MS = 60_000;
const DAY_MS = 24 * 60 * 60 * 1000;
const SWEEP_INTERVAL_MS = 10 * MINUTE_MS;

function clientIp(c: Context, trustProxy: boolean): string {
if (trustProxy) {
const forwardedFor = c.req.header('x-forwarded-for');
const first = forwardedFor?.split(',')[0]?.trim();
if (first) {
return first;
}
}
try {
return getConnInfo(c).remote.address ?? 'unknown';
} catch {
// no underlying socket (app.request() in tests)
return 'unknown';
}
}

function hitWindow(state: WindowState, limit: number, durationMs: number, now: number): number | null {
if (limit <= 0) {
return null;
}
if (now - state.windowStart >= durationMs) {
state.windowStart = now;
state.count = 0;
}
if (state.count >= limit) {
return state.windowStart + durationMs - now;
}
return null;
}

// In-memory fixed windows: counters reset on restart and are not shared
// across replicas — fine for the single-replica demo deployment.
export function createRateLimitMiddleware(options: RateLimitOptions): MiddlewareHandler {
const { perMinute, perDay, trustProxy } = options;
const now = options.now ?? Date.now;
const states = new Map<string, IpState>();
let lastSweep = now();

return async (c, next) => {
const timestamp = now();

if (timestamp - lastSweep >= SWEEP_INTERVAL_MS) {
lastSweep = timestamp;
for (const [ip, state] of states) {
if (timestamp - state.day.windowStart >= DAY_MS && timestamp - state.minute.windowStart >= MINUTE_MS) {
states.delete(ip);
}
}
}

const ip = clientIp(c, trustProxy);
let state = states.get(ip);
if (!state) {
state = {
minute: { windowStart: timestamp, count: 0 },
day: { windowStart: timestamp, count: 0 },
};
states.set(ip, state);
}

const minuteRetry = hitWindow(state.minute, perMinute, MINUTE_MS, timestamp);
const dayRetry = hitWindow(state.day, perDay, DAY_MS, timestamp);
const retryAfterMs = Math.max(minuteRetry ?? 0, dayRetry ?? 0);

if (retryAfterMs > 0) {
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
c.header('Retry-After', String(retryAfterSeconds));
return c.json(
{
code: 'rate_limited',
message: 'Too many workflow executions from this address — try again later',
retryAfterSeconds,
},
429,
);
}

state.minute.count += 1;
state.day.count += 1;

await next();
};
}
22 changes: 22 additions & 0 deletions apps/backend/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import {
createAuthMiddleware,
makeAssertAuthorized,
} from './auth';
import { runMigrations } from './db/migrate';
import { env } from './env';
import { logger } from './logger';
import { createRateLimitMiddleware } from './middleware/rate-limit';
import { createExecutionsRoutes } from './routes/executions';
import { createWorkflowsRoutes } from './routes/workflows';

Expand Down Expand Up @@ -52,9 +54,29 @@ app.get('/api/health', (c) => c.json({ status: 'ok' }));

app.use('/api/*', createAuthMiddleware(authPort));

if (env.RATE_LIMIT_EXECUTE_PER_MINUTE > 0 || env.RATE_LIMIT_EXECUTE_PER_DAY > 0) {
app.use(
'/api/workflows/:id/execute',
createRateLimitMiddleware({
perMinute: env.RATE_LIMIT_EXECUTE_PER_MINUTE,
perDay: env.RATE_LIMIT_EXECUTE_PER_DAY,
trustProxy: env.TRUST_PROXY,
}),
);
logger.info('execute rate limit enabled', {
perMinute: env.RATE_LIMIT_EXECUTE_PER_MINUTE,
perDay: env.RATE_LIMIT_EXECUTE_PER_DAY,
trustProxy: env.TRUST_PROXY,
});
}

app.route('/api/workflows', createWorkflowsRoutes(assertAuthorized));
app.route('/api/executions', createExecutionsRoutes(assertAuthorized));

// a failure (DB still starting) exits the process; the container restart policy retries
await runMigrations();
logger.info('database migrations applied');

serve({ fetch: app.fetch, port: env.PORT, hostname: env.HOST }, () => {
logger.info('backend listening', { url: `http://${env.HOST}:${env.PORT}` });
});
Loading
Loading