smithery/tombensim

express-api-patterns

Express.js API patterns for TypeScript backends with raw SQL (pg), JWT auth, RBAC, and Zod validation.

Installation

$ npx skills add smithery/tombensim --skill express-api-patterns

Summary

  • Express.js API patterns for TypeScript backends with raw SQL (pg), JWT auth, RBAC, and Zod validation.
  • This skill should be used when writing API routes, middleware, database queries, or auth logic.
  • Triggers on tasks involving Express endpoints, middleware, SQL queries, authentication, or authorization.

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from smithery/tombensim.

npx skills add smithery/tombensim

Browse all from smithery/tombensim

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Skill metadata

Parsed from SKILL.md frontmatter.

Version1.0.0
LicenseMIT
More metadata
author
tennis-team
version
1.0.0

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 8,488 B
  • docs SUMMARY.md 332 B

History

  1. First recorded snapshot · 0 installs

SKILL.md

Express API Patterns

Best practices for building Express.js REST APIs with TypeScript, raw SQL (pg), JWT authentication, and role-based access control.

When to Apply

Reference these guidelines when:

  • Creating new API routes or modules
  • Writing database queries with pg
  • Implementing authentication or authorization logic
  • Adding request validation with Zod
  • Writing middleware
  • Handling errors

Rule Categories

Category Impact Prefix
SQL Safety CRITICAL sql-
Auth & JWT CRITICAL auth-
Validation HIGH validate-
Error Handling HIGH error-
Module Structure MEDIUM module-
Query Patterns MEDIUM query-

Rules

SQL Safety (CRITICAL)

sql-parameterized

ALWAYS use parameterized queries. NEVER interpolate user input into SQL strings.

Incorrect: SQL injection vulnerability

const result = await pool.query(`SELECT * FROM users WHERE email = '${email}'`);

Correct: parameterized query

const result = await pool.query('SELECT * FROM users WHERE email = $1', [email]);

sql-transactions

Use transactions for multi-table writes. Always rollback on error.

const client = await pool.connect();
try {
  await client.query('BEGIN');

  const {
    rows: [txn],
  } = await client.query(
    `INSERT INTO ledger_transactions (group_id, type, amount, description)
     VALUES ($1, $2, $3, $4) RETURNING id`,
    [groupId, 'CHARGE', amount, description],
  );

  await client.query(
    `INSERT INTO ledger_entries (transaction_id, account_id, type, amount)
     VALUES ($1, $2, 'DEBIT', $3), ($1, $4, 'CREDIT', $3)`,
    [txn.id, memberAccountId, amount, revenueAccountId],
  );

  await client.query('COMMIT');
  return txn;
} catch (err) {
  await client.query('ROLLBACK');
  throw err;
} finally {
  client.release();
}

sql-migrations

Use numbered SQL migration files. Never modify existing migrations; always create new ones.

db/migrations/
  001_users_auth_groups.sql
  002_events_participants.sql
  003_tournaments.sql
  004_ledger.sql
  005_notifications.sql

Auth & JWT (CRITICAL)

auth-token-storage

Store access tokens in memory only. Use httpOnly Secure SameSite=Strict cookies for refresh tokens.

// Set refresh token as cookie
res.cookie('refreshToken', token, {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'strict',
  maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
  path: '/api/v1/auth/refresh',
});

// Access token returned in response body (stored in memory by frontend)
res.json({ accessToken, user });

auth-refresh-rotation

Rotate refresh tokens on every use. Revoke the old token.

async function refreshTokens(oldToken: string) {
  const hashed = sha256(oldToken);
  const stored = await pool.query(
    'SELECT * FROM refresh_tokens WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()',
    [hashed],
  );
  if (!stored.rows[0]) throw new UnauthorizedError('Invalid refresh token');

  // Revoke old token
  await pool.query('UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = $1', [
    stored.rows[0].id,
  ]);

  // Issue new pair
  const newRefreshToken = generateRefreshToken();
  await pool.query(
    'INSERT INTO refresh_tokens (user_id, token_hash, expires_at, device_info) VALUES ($1, $2, $3, $4)',
    [stored.rows[0].user_id, sha256(newRefreshToken), addDays(30), deviceInfo],
  );

  const accessToken = signAccessToken(stored.rows[0].user_id);
  return { accessToken, refreshToken: newRefreshToken };
}

auth-rbac-middleware

Check roles against group_memberships, not a global role.

function requireRole(...roles: GroupRole[]) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const groupId = req.params.groupId || req.body.groupId;
    if (!groupId) return res.status(400).json({ error: 'Group ID required' });

    const { rows } = await pool.query(
      `SELECT role FROM group_memberships
       WHERE user_id = $1 AND group_id = $2 AND status = 'active'`,
      [req.user.id, groupId],
    );

    if (!rows[0] || !roles.includes(rows[0].role)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }

    req.membership = rows[0];
    next();
  };
}

// Usage
router.post('/events', authenticate, requireRole('owner', 'admin', 'coach'), createEvent);

Validation (HIGH)

validate-zod-middleware

Validate request body, params, and query with Zod schemas from the shared package.

import { z } from 'zod';

function validate(schema: { body?: z.ZodSchema; params?: z.ZodSchema; query?: z.ZodSchema }) {
  return (req: Request, res: Response, next: NextFunction) => {
    try {
      if (schema.body) req.body = schema.body.parse(req.body);
      if (schema.params) req.params = schema.params.parse(req.params) as any;
      if (schema.query) req.query = schema.query.parse(req.query) as any;
      next();
    } catch (err) {
      if (err instanceof z.ZodError) {
        return res.status(400).json({ error: 'Validation failed', details: err.errors });
      }
      next(err);
    }
  };
}

Error Handling (HIGH)

error-centralized-handler

Use a centralized error handler middleware. Throw typed errors from route handlers.

class AppError extends Error {
  constructor(
    public statusCode: number,
    message: string,
  ) {
    super(message);
  }
}
class NotFoundError extends AppError {
  constructor(message = 'Not found') {
    super(404, message);
  }
}
class UnauthorizedError extends AppError {
  constructor(message = 'Unauthorized') {
    super(401, message);
  }
}
class ForbiddenError extends AppError {
  constructor(message = 'Forbidden') {
    super(403, message);
  }
}
class ConflictError extends AppError {
  constructor(message = 'Conflict') {
    super(409, message);
  }
}

// Error handler middleware (register last)
function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({ error: err.message });
  }
  logger.error(err);
  res.status(500).json({ error: 'Internal server error' });
}

Module Structure (MEDIUM)

module-file-structure

Each API module follows a consistent file structure.

modules/events/
  events.routes.ts    # Router with route definitions
  events.handlers.ts  # Request handlers (thin: validate, call service, respond)
  events.service.ts   # Business logic + DB queries
  events.schemas.ts   # Zod schemas for this module
  events.types.ts     # Module-specific TypeScript types

Handlers are thin — they extract validated input, call the service, and send the response. All business logic lives in the service layer.

Query Patterns (MEDIUM)

query-pagination

Use cursor-based or offset pagination consistently.

async function listEvents(groupId: string, cursor?: string, limit = 20) {
  const params: any[] = [groupId, limit + 1];
  let where = 'WHERE e.group_id = $1';

  if (cursor) {
    where += ' AND e.start_time > $3';
    params.push(cursor);
  }

  const { rows } = await pool.query(
    `SELECT e.*, c.name as court_name
     FROM events e
     LEFT JOIN courts c ON e.court_id = c.id
     ${where}
     ORDER BY e.start_time ASC
     LIMIT $2`,
    params,
  );

  const hasMore = rows.length > limit;
  if (hasMore) rows.pop();

  return {
    data: rows,
    nextCursor: hasMore ? rows[rows.length - 1].start_time : null,
  };
}

query-returning

Use RETURNING clause to avoid a second SELECT after INSERT/UPDATE.

const {
  rows: [event],
} = await pool.query(
  `INSERT INTO events (group_id, title, court_id, start_time, end_time, capacity)
   VALUES ($1, $2, $3, $4, $5, $6)
   RETURNING *`,
  [groupId, title, courtId, startTime, endTime, capacity],
);