Install
$ agentstack add skill-san-npm-skills-ws-api-design ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ● Network access Used
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
API Design
REST Conventions That Actually Matter
Forget the academic debates about REST maturity levels. Here's what matters in practice:
URL Design
# Resources are nouns, plural
GET /api/v1/users # List users
POST /api/v1/users # Create user
GET /api/v1/users/:id # Get user
PATCH /api/v1/users/:id # Partial update
PUT /api/v1/users/:id # Full replace (rare)
DELETE /api/v1/users/:id # Delete user
# Nesting: max 2 levels deep
GET /api/v1/users/:id/orders # User's orders
GET /api/v1/users/:id/orders/:orderId # Specific order
# Don't nest deeper — use query params instead
# BAD: /api/v1/users/:id/orders/:orderId/items/:itemId
# GOOD: /api/v1/order-items/:itemId
# GOOD: /api/v1/orders/:orderId/items?expand=product
# Actions that don't map to CRUD — use verb sub-resources
POST /api/v1/users/:id/verify-email
POST /api/v1/orders/:id/cancel
POST /api/v1/reports/generate
Filtering, Sorting, Pagination
# Filtering — use query params with field names
GET /api/v1/users?status=active&role=admin&created_after=2024-01-01
# Sorting — comma-separated, prefix with - for descending
GET /api/v1/users?sort=-created_at,name
# Field selection — reduce payload
GET /api/v1/users?fields=id,name,email
# Search — use q for full-text
GET /api/v1/users?q=john&status=active
# Combining
GET /api/v1/orders?status=pending&sort=-created_at&limit=20&cursor=eyJ...
Pagination: Cursor vs Offset
Offset Pagination (Simple, Flawed)
// Simple but problematic for large datasets
app.get('/api/v1/users', async (req, res) => {
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const offset = (page - 1) * limit;
const [users, total] = await Promise.all([
db.query('SELECT * FROM users ORDER BY id LIMIT $1 OFFSET $2', [limit, offset]),
db.query('SELECT COUNT(*) FROM users'),
]);
res.json({
data: users.rows,
pagination: {
page,
limit,
total: parseInt(total.rows[0].count),
totalPages: Math.ceil(parseInt(total.rows[0].count) / limit),
},
});
});
Problems with offset pagination:
OFFSET 100000scans and discards 100k rows — O(n)- Inserting/deleting rows between pages causes duplicates/gaps
- COUNT(*) on large tables is slow
Cursor Pagination (Production-Grade)
// Cursor-based — consistent, performant, no skipping
app.get('/api/v1/users', async (req, res) => {
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const cursor = req.query.cursor as string | undefined;
let query = 'SELECT * FROM users';
const params: any[] = [limit + 1]; // Fetch one extra to detect hasMore
if (cursor) {
const decoded = decodeCursor(cursor); // { id: 123, created_at: '2024-01-01' }
query += ' WHERE (created_at, id) limit;
const items = hasMore ? result.rows.slice(0, -1) : result.rows;
const nextCursor = hasMore
? encodeCursor({
id: items[items.length - 1].id,
created_at: items[items.length - 1].created_at,
})
: null;
res.json({
data: items,
pagination: {
next_cursor: nextCursor,
has_more: hasMore,
},
});
});
// Cursor encoding — base64 JSON (not security, just obfuscation)
function encodeCursor(data: Record): string {
return Buffer.from(JSON.stringify(data)).toString('base64url');
}
function decodeCursor(cursor: string): Record {
return JSON.parse(Buffer.from(cursor, 'base64url').toString());
}
Keyset Pagination for Large Datasets
For tables with 10M+ rows, keyset pagination on an indexed column:
-- Requires composite index: CREATE INDEX idx_users_created_id ON users(created_at DESC, id DESC);
SELECT * FROM users
WHERE (created_at, id) {
const user = await db.findUser(req.params.id);
if (!user) throw new NotFoundError('User', req.params.id);
res.json({ data: user });
});
app.post('/api/v1/users', async (req, res) => {
const errors: FieldError[] = [];
if (!req.body.email) errors.push({ field: 'email', message: 'Email is required', code: 'REQUIRED' });
if (!req.body.name) errors.push({ field: 'name', message: 'Name is required', code: 'REQUIRED' });
if (errors.length) throw new ValidationError(errors);
const existing = await db.findUserByEmail(req.body.email);
if (existing) throw new ConflictError('A user with this email already exists');
const user = await db.createUser(req.body);
res.status(201).json({ data: user });
});
Error Response Examples
404 Not Found:
{
"type": "https://api.example.com/errors/resource_not_found",
"title": "resource not found",
"status": 404,
"detail": "User with id 'abc-123' not found",
"instance": "/api/v1/users/abc-123",
"code": "RESOURCE_NOT_FOUND",
"traceId": "req-xyz-789"
}
422 Unprocessable Entity with field-level errors:
{
"type": "https://api.example.com/errors/validation_error",
"title": "validation error",
"status": 422,
"detail": "Request validation failed",
"code": "VALIDATION_ERROR",
"errors": [
{ "field": "email", "message": "Must be a valid email address", "code": "INVALID_FORMAT" },
{ "field": "age", "message": "Must be at least 18", "code": "MIN_VALUE" }
]
}
API Versioning
URL Versioning (Preferred for Public APIs)
/api/v1/users
/api/v2/users
Simple, explicit, easy to route. The pragmatic choice.
Header Versioning (Alternative)
Accept: application/vnd.myapi.v2+json
More "RESTful" but harder to test (can't just paste a URL).
Deprecation Strategy
// middleware/deprecation.ts
function deprecationWarning(sunset: string, alternative: string) {
return (req: Request, res: Response, next: NextFunction) => {
res.setHeader('Deprecation', 'true');
res.setHeader('Sunset', sunset); // RFC 8594
res.setHeader('Link', `; rel="successor-version"`);
next();
};
}
// Usage — Sunset must be an HTTP-date (RFC 8594 / RFC 7231), in the future
app.get('/api/v1/users',
deprecationWarning('Wed, 01 Jul 2026 00:00:00 GMT', '/api/v2/users'),
v1UserHandler,
);
Versioning Timeline
v1 released → v2 released → v1 deprecated (6 month warning) → v1 sunset (returns 410 Gone)
Rate Limiting
Sliding Window Log with Redis (Production)
A sorted set stores one member per request, scored by timestamp. Each call trims entries older than the window, adds the current request, and counts what remains — giving an exact rolling count with no fixed-window burst seam. Cost is O(log N) per request and memory is O(requests-in-window) per key, so for very high-volume limits prefer a token-bucket / GCRA counter (constant memory) — see the atomic Lua variant below.
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number;
retryAfter?: number;
}
async function checkRateLimit(
key: string,
maxRequests: number,
windowSeconds: number,
): Promise {
const now = Math.floor(Date.now() / 1000);
const windowStart = now - windowSeconds;
// Sliding window log using sorted set
const pipeline = redis.pipeline();
pipeline.zremrangebyscore(key, 0, windowStart); // Remove old entries
pipeline.zadd(key, now.toString(), `${now}:${Math.random()}`); // Add current
pipeline.zcard(key); // Count in window
pipeline.expire(key, windowSeconds); // TTL cleanup
const results = await pipeline.exec();
const count = results![2][1] as number;
if (count > maxRequests) {
const oldestInWindow = await redis.zrange(key, 0, 0, 'WITHSCORES');
const retryAfter = oldestInWindow.length >= 2
? parseInt(oldestInWindow[1]) + windowSeconds - now
: windowSeconds;
return {
allowed: false,
remaining: 0,
resetAt: now + retryAfter,
retryAfter,
};
}
return {
allowed: true,
remaining: maxRequests - count,
resetAt: now + windowSeconds,
};
}
// Middleware
function rateLimit(maxRequests: number, windowSeconds: number) {
return async (req: Request, res: Response, next: NextFunction) => {
// Per-user if authenticated, per-IP otherwise
const key = req.user
? `ratelimit:user:${req.user.id}`
: `ratelimit:ip:${req.ip}`;
const result = await checkRateLimit(key, maxRequests, windowSeconds);
// Standardized headers (RFC 9333). `RateLimit-Reset` is seconds-until-reset
// (a delta), not an epoch timestamp — that's the key difference from the
// legacy `X-RateLimit-Reset` convention below.
const resetDelta = Math.max(0, result.resetAt - Math.floor(Date.now() / 1000));
res.setHeader('RateLimit-Limit', maxRequests);
res.setHeader('RateLimit-Remaining', result.remaining);
res.setHeader('RateLimit-Reset', resetDelta);
// Legacy headers — keep for older clients; `X-RateLimit-Reset` is an epoch.
res.setHeader('X-RateLimit-Limit', maxRequests);
res.setHeader('X-RateLimit-Remaining', result.remaining);
res.setHeader('X-RateLimit-Reset', result.resetAt);
if (!result.allowed) {
res.setHeader('Retry-After', result.retryAfter!); // seconds (RFC 7231)
throw new RateLimitError(result.retryAfter!);
}
next();
};
}
// Different limits for different endpoints
app.use('/api/v1/auth', rateLimit(10, 60)); // 10/min for auth
app.use('/api/v1/', rateLimit(100, 60)); // 100/min general
app.use('/api/v1/search', rateLimit(30, 60)); // 30/min for search
Atomic Token Bucket (Lua) — constant memory, allows bursts
The sliding-window pipeline above is two round-trips and stores one key per request. A token bucket runs as a single atomic Lua script (no race between read and write under concurrency), uses O(1) memory per key, and naturally permits short bursts up to capacity while enforcing a steady refill rate.
// Refills `refillRate` tokens/sec up to `capacity`; each request costs 1 token.
// KEYS[1] = bucket key. ARGV: capacity, refillRate, now (sec, fractional), cost.
const TOKEN_BUCKET = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local state = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(state[1])
local ts = tonumber(state[2])
if tokens == nil then tokens = capacity; ts = now end
-- Refill based on elapsed time, cap at capacity
tokens = math.min(capacity, tokens + (now - ts) * refillRate)
local allowed = 0
if tokens >= cost then
allowed = 1
tokens = tokens - cost
end
redis.call('HSET', key, 'tokens', tokens, 'ts', now)
-- Expire when the bucket would be full again (idle reclaim)
redis.call('EXPIRE', key, math.ceil(capacity / refillRate) + 1)
-- Seconds until enough tokens for one request (0 if allowed now)
local retry = 0
if allowed == 0 then retry = (cost - tokens) / refillRate end
return { allowed, tostring(tokens), tostring(retry) }
`;
const sha = await redis.script('LOAD', TOKEN_BUCKET);
async function checkTokenBucket(
key: string, capacity: number, refillRate: number, cost = 1,
): Promise {
const now = Date.now() / 1000;
const [allowed, tokensStr, retryStr] = (await redis.evalsha(
sha, 1, key, capacity, refillRate, now, cost,
)) as [number, string, string];
const remaining = Math.floor(parseFloat(tokensStr));
const retryAfter = Math.ceil(parseFloat(retryStr));
return {
allowed: allowed === 1,
remaining,
resetAt: Math.floor(now) + Math.ceil((capacity - remaining) / refillRate),
...(allowed === 1 ? {} : { retryAfter }),
};
}
// e.g. checkTokenBucket('ratelimit:user:42', 100, 100 / 60) → 100 burst, refills to 100/min
Authentication Patterns
JWT Access + Refresh Token (Fastify)
import Fastify, { FastifyRequest, FastifyReply } from 'fastify';
import jwt from '@fastify/jwt';
const app = Fastify();
await app.register(jwt, {
secret: process.env.JWT_SECRET!,
sign: { expiresIn: '15m' }, // Short-lived access tokens
});
// Decorate the `authenticate` preHandler used by protected routes below.
// Without this decorator the `preHandler: [app.authenticate]` example throws.
app.decorate('authenticate', async (request, reply) => {
try {
await request.jwtVerify(); // populates request.user from the Bearer token
} catch {
throw new AppError(401, 'UNAUTHENTICATED', 'Missing or invalid access token');
}
});
// TypeScript: augment Fastify so `app.authenticate` and `request.user` type-check.
declare module 'fastify' {
interface FastifyInstance {
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise;
}
}
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: { sub: string; role: string }; // sign() input
user: { sub: string; role: string }; // request.user shape
}
}
// Refresh tokens use a SELECTOR.SECRET design so lookup is a single indexed
// query, never a scan over every active hash:
// - selector: random id, stored in plaintext, UNIQUE-indexed — used to find the row
// - secret: random, stored only as an argon2 hash — verified in constant time
// - familyId: groups every token descended from one login, so reuse of a
// rotated token can revoke the whole family (theft detection)
// Wire format handed to the client is `${selector}.${secret}`.
function issueRefreshToken(userId: string, familyId: string) {
const selector = crypto.randomBytes(16).toString('base64url');
const secret = crypto.randomBytes(32).toString('base64url');
return { token: `${selector}.${secret}`, selector, secret, familyId, userId };
}
const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
// Login
app.post('/api/v1/auth/login', async (req, reply) => {
const { email, password } = req.body as { email: string; password: string };
const user = await db.findUserByEmail(email);
if (!user || !await argon2.verify(user.passwordHash, password)) {
throw new AppError(401, 'INVALID_CREDENTIALS', 'Invalid email or password');
}
const accessToken = app.jwt.sign({ sub: user.id, role: user.role });
const familyId = crypto.randomUUID();
const rt = issueRefreshToken(user.id, familyId);
await db.storeRefreshToken({
selector: rt.selector,
secretHash: await argon2.hash(rt.secret), // never store the raw secret
userId: rt.userId,
familyId: rt.familyId,
expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
});
reply.send({ accessToken, refreshToken: rt.token, expiresIn: 900 });
});
// Refresh — rotate, and detect reuse of an already-rotated token
app.post('/api/v1/auth/refresh', async (req, reply) => {
const { refreshToken } = req.body as { refreshToken: string };
const [selector, secret] = (refreshToken ?? '').split('.');
if (!selector || !secret) {
throw new AppError(401, 'INVALID_TOKEN', 'Malformed refresh token');
}
// Single indexed lookup by selector — O(1), no hash scan.
const row = await db.findRefreshTokenBySelector(selector);
if (!row || !await argon2.verify(row.secretHash, secret)) {
throw new AppError(401, 'INVALID_TOKEN', 'Invalid refresh token');
}
// Reuse detection: a token that's already been consumed/revoked but is
// presented again means it was likely stolen → kill the whole family.
if (row.consumedAt || row.revokedAt || row.expiresAt {
const user = await db.findUser(req.user.sub);
reply.send({ data: user });
});
API Keys (Service-to-Service)
// Generate API keys
function generateApiKey(): { key: string; hash: string; prefix: string } {
const key = `sk_live_${crypto.randomBytes(32).toString('base64url')}`;
const prefix = key.slice(0, 12); // For identificatio
…
## Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- **Author:** [san-npm](https://github.com/san-npm)
- **Source:** [san-npm/skills-ws](https://github.com/san-npm/skills-ws)
- **License:** MIT
- **Homepage:** https://skills-ws.vercel.app
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.