Skip to main content
Thank you for your interest in contributing to rs-tunnel! This guide will help you get started.

Getting Started

Before contributing, make sure you have:
  1. Read the Local Setup guide
  2. Familiarized yourself with the Monorepo Structure
  3. Reviewed the Testing documentation

Development Workflow

1

Fork and clone

Fork the repository on GitHub and clone your fork:
git clone https://github.com/YOUR_USERNAME/rs-tunnel.git
cd rs-tunnel
2

Install dependencies

pnpm install
3

Create a branch

Create a descriptive branch for your work:
git checkout -b feature/your-feature-name
# or
git checkout -b fix/issue-description
4

Make your changes

Write code, add tests, and update documentation as needed.
5

Run quality checks

Before committing, ensure all checks pass:
pnpm lint
pnpm typecheck
pnpm test
pnpm build
All four commands must pass. These same checks run in CI and will block merging if they fail.
6

Commit your changes

Write clear, concise commit messages:
git add .
git commit -m "feat: add support for custom domain validation"
7

Push and create PR

git push origin feature/your-feature-name
Then open a Pull Request on GitHub.

Quality Gates

All contributions must pass these quality gates:

Lint

ESLint checks code style and catches common mistakes.
pnpm lint
Fix auto-fixable issues:
pnpm lint --fix

Typecheck

TypeScript ensures type safety across the codebase.
pnpm typecheck
Must pass with zero errors.

Test

Vitest runs unit and integration tests.
pnpm test
Add tests for new features and bug fixes.

Build

TypeScript Compiler ensures the project builds successfully.
pnpm build
Verifies all packages compile without errors.

Project Rules

TypeScript

The project uses TypeScript strict mode. Key requirements:
  • No implicit any types
  • Strict null checks enabled
  • All function parameters and return types should be typed
  • Use unknown instead of any when type is truly unknown
// ❌ Avoid
function process(data: any) {
  return data.value;
}

// ✅ Prefer
function process(data: { value: string }): string {
  return data.value;
}

Security

Never commit secrets! Provider credentials must stay in the API runtime only.

API-Only Secrets

These secrets belong ONLY in API runtime:
  • CLOUDFLARE_API_TOKEN
  • SLACK_CLIENT_SECRET
  • JWT_SECRET
  • REFRESH_TOKEN_SECRET
CLI receives short-lived tokens from API, never provider credentials.

No Logging

Never log sensitive data:
  • Secrets or API tokens
  • JWTs or refresh tokens
  • User passwords
  • Full Cloudflare API responses
Use [REDACTED] in logs when debugging auth flows.

Least Privilege

Cloudflare API token should have minimal scopes:
  • Tunnel: Read and Write
  • DNS: Read and Write
  • Zone: Read only
No account-level admin permissions.

Token Expiry

Respect token lifetimes:
  • Access tokens: 15 minutes (configurable)
  • Refresh tokens: 30 days (configurable)
  • Tunnel run tokens: Tied to tunnel lifecycle
Never extend lifetimes without security review.

Pull Request Checklist

Before opening a PR, ensure:
  • All quality gates pass (lint, typecheck, test, build)
  • Tests added/updated for behavioral changes
  • Documentation updated if needed:
    • README.md for setup/command changes
    • .env.example for new environment variables
    • Inline code comments for complex logic
  • No secret values in committed files
  • Commit messages are clear and descriptive
  • PR description explains the “why” behind changes
  • Breaking changes are clearly documented

PR Template

When opening a PR, include:
## Description
Brief description of what this PR does and why.

## Changes
- Bullet list of key changes
- Include both code and configuration changes

## Testing
How to test these changes:
1. Step-by-step testing instructions
2. Expected behavior

## Breaking Changes
- List any breaking changes
- Include migration guide if applicable

## Related Issues
Fixes #123

Code Style Conventions

// Functions: camelCase, descriptive verbs
function validateRequestedSlug(slug: string): string { }
function createTunnel(params: TunnelParams): Promise<Tunnel> { }

// Variables: camelCase, descriptive nouns
const maxActiveTunnels = 5;
const databaseUrl = process.env.DATABASE_URL;

// Types/Interfaces: PascalCase
interface TunnelCreateRequest { }
type TunnelStatus = 'active' | 'stopping' | 'stopped';

// Constants: UPPER_SNAKE_CASE
const MAX_SLUG_LENGTH = 63;
const DEFAULT_HEARTBEAT_INTERVAL = 20;

Release Process

For Maintainers Only: The release process is automated and requires admin access.

Tag-Driven Releases

Releases are triggered by pushing version tags:
# Update version in package.json files
pnpm --filter @ripeseed/shared version patch
pnpm --filter @ripeseed/rs-tunnel version patch

# Commit version bumps
git add .
git commit -m "chore: bump version to 0.1.3"

# Create and push tag
git tag v0.1.3
git push origin main --tags

Release Workflow

The .github/workflows/release.yml workflow:
.github/workflows/release.yml
name: Release Packages

on:
  push:
    tags:
      - "v*.*.*"

jobs:
  publish-packages:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          registry-url: https://registry.npmjs.org/
          cache: pnpm
      - run: pnpm install
      - run: pnpm --filter @ripeseed/shared build
      - run: pnpm --filter @ripeseed/shared publish --no-git-checks --access public
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
      - run: pnpm --filter @ripeseed/rs-tunnel build
      - run: pnpm --filter @ripeseed/rs-tunnel publish --no-git-checks --access public
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Publish Order: @ripeseed/shared must publish before @ripeseed/rs-tunnel because the CLI depends on the shared package.

Required GitHub Secret

The release workflow requires a GitHub Actions secret:
  • NPM_TOKEN: npm access token with publish rights for @ripeseed scope
To create an npm token:
  1. Log in to npmjs.com
  2. Go to Access Tokens → Generate New Token
  3. Select “Automation” type
  4. Add publish scope for @ripeseed
  5. Add token to GitHub repository secrets as NPM_TOKEN

Post-Release

After packages publish:
# Users can install the new version
npm install -g @ripeseed/rs-tunnel@latest

# Verify installation
rs-tunnel --version

Non-Negotiable Product Constraints

When contributing, respect these core product rules:
Only emails ending in ALLOWED_EMAIL_DOMAIN are allowed.
// API must validate email domain
if (!email.endsWith(process.env.ALLOWED_EMAIL_DOMAIN)) {
  throw new Error('Unauthorized email domain');
}
Slack workspace must match ALLOWED_SLACK_TEAM_ID.
// API must verify Slack team ID during OAuth
if (slackTeamId !== process.env.ALLOWED_SLACK_TEAM_ID) {
  throw new Error('Unauthorized Slack workspace');
}
No nested domains allowed. Slugs must be single-label DNS labels.
// ✅ Valid
validateRequestedSlug('my-app');      // OK
validateRequestedSlug('demo-123');     // OK

// ❌ Invalid
validateRequestedSlug('my.app');       // Nested domain - reject
validateRequestedSlug('sub.demo.app'); // Multiple dots - reject
Maximum 5 active tunnels per user (server-side enforcement).
// API must enforce quota before creating tunnel
const activeCount = await db.countActiveTunnels(userId);
if (activeCount >= MAX_ACTIVE_TUNNELS) {
  throw new Error('Maximum active tunnels reached');
}
DNS records must be deleted when tunnel stops.
// On tunnel stop, cleanup MUST include DNS
async function stopTunnel(tunnelId: string) {
  await cloudflare.deleteDnsRecord(tunnelId);
  await cloudflare.deleteTunnel(tunnelId);
  await db.markTunnelStopped(tunnelId);
}
If client dies, cleanup worker must remove tunnel + DNS.
// Reaper runs periodically and cleans stale leases
const staleLeases = await db.findStaleTunnels(
  Date.now() - LEASE_TIMEOUT_SEC * 1000
);

for (const tunnel of staleLeases) {
  await cleanupTunnel(tunnel.id);
}
CLI must never hold Cloudflare API credentials.
// ✅ CLI receives tunnel-specific token from API
const { tunnelToken } = await api.createTunnel({ slug, port });
await runCloudflared(tunnelToken);

// ❌ CLI should never have CLOUDFLARE_API_TOKEN
const cloudflare = new CloudflareClient(process.env.CLOUDFLARE_API_TOKEN);

Getting Help

If you need help:
For security vulnerabilities, do NOT open public issues. Follow the responsible disclosure process in SECURITY.md.

Code of Conduct

This project follows the Contributor Covenant Code of Conduct. Be respectful and professional in all interactions.

License

By contributing, you agree that your contributions will be licensed under the MIT License.

Next Steps

Local Setup

Set up your development environment

Testing

Learn about testing practices

Monorepo Structure

Understand the codebase organization

Architecture

Learn about system architecture

Build docs developers (and LLMs) love