wshobson--agents
be57c0b2e3
* feat(adapters): multi-harness framework + harness_portability eval dimension
Turn this Claude Code plugin marketplace into a generic agentic-harness
marketplace. Adapters under tools/adapters/ emit harness-native artifacts
for OpenAI Codex CLI, Cursor, OpenCode, and Gemini CLI from a single
Markdown source. Source-of-truth stays under plugins/ — Claude Code is
unchanged.
Framework (tools/adapters/):
- base.py — PluginSource parser, HarnessAdapter ABC, write/mirror helpers
(path-traversal guard, UTF-8-safe), inline-list + block-list + block-scalar
YAML-ish parser, _utf8_safe_cut, _split_inline_list, _normalize_author
- capabilities.py — per-harness capability matrix, TOOL_NAME_MAPS,
MODEL_ALIASES, resolve_model() with explicit warnings
- codex.py — emits .codex/{skills,agents}/ + AGENTS.md (≤150-line
table-of-contents). Fence-aware body splitter, _utf8_safe_cut for
multibyte safety, _yaml_scalar with reserved-word + special-char quoting.
Skill/command name collision detection (and second-order __cmd fallback).
- cursor.py — emits .cursor-plugin/{plugin,marketplace}.json + curated
.cursor/rules/*.mdc. _validate_mdc_frontmatter handles YAML block scalars
(no false positives on colons in description body). _normalize_author
handles dict, npm-style strings, and author lists.
- opencode.py — transpiles agents to .opencode/agents/<id>.md with
mode:subagent + permission: deny-everything-else block (skill/task always
allowed as base capabilities — Claude's implicit defaults).
- gemini.py — emits native skills/, agents/, and commands/ at extension
root (April 2026 spec). Tool-allowlist remapped via TOOL_NAME_MAPS.
CLI + tooling:
- tools/generate.py — unified `make generate HARNESS=<x> [PLUGIN=<y>]`,
with --clean (containment-guarded; case-insensitive on Darwin/Win32),
--prune (orphan removal across all per-harness output trees), --strict
(warnings fail), per-plugin error aggregation, refuses --clean --plugin
(would silently wipe other plugins' artifacts).
- tools/validate_generated.py — structural validation across all four
harness outputs. Codex 8KB cap → error. _extract_permission_block
correctly handles nested permission keys (column-0 only).
- tools/doc_gardener.py — recurring drift detection per OpenAI harness-
engineering principle. STALE_ARTIFACT (info), DEAD_LINK (error),
MARKETPLACE_ORPHAN (error), SKILL_OVER_CODEX_CAP (warning), grouped
output sorted by severity.
plugin-eval (extends existing framework):
- New harness_portability dimension (6% weight, rebalanced from existing
static sub-scores). Surfaces non-portable patterns with concrete
remediation hints: SKILL_OVER_CODEX_CAP, CLAUDE_TOOL_REFS,
CLAUDE_TOOL_PROSE, AGENT_NAME_COLLISION, BARE_MODEL_ALIAS.
- _CAMEL_TOOL_PATTERN requires Claude-tool context (no false positives
on Rust's `Task` etc.). _TOOL_PROSE_PATTERN case-sensitive on tool
names, case-insensitive on the leading article.
- Findings do NOT also feed anti_pattern_penalty (no double-counting).
Documentation:
- Top-level guides: CODEX.md, CURSOR.md, OPENCODE.md (≤150 lines each,
table-of-contents pattern per OpenAI harness-engineering post)
- docs/harnesses.md — capability matrix, graceful-degradation table,
generated output paths
- docs/authoring.md — portable-content style guide (tools, models,
collision rules, fence-respect)
- docs/round-trip-results.md — real-CLI verification recipes (OpenCode
discovers 193 subagents, Gemini extensions validate passes, Codex
TOMLs all parse)
- CONTRIBUTING.md — new file pointing at docs/authoring.md
- README.md — rewritten for multi-harness (145 lines, was 460)
- CLAUDE.md — trimmed to 60-line table-of-contents
- GEMINI.md — trimmed from 1500 to 500 tokens (3× over budget previously)
Tests: 181 passing (103 plugin-eval + 78 tools/tests). Real-CLI round-trip
verified for OpenCode, Gemini, and Codex (TOML parses).
Replaces tools/generate_gemini_commands.py with the unified CLI.
* refactor(skills): extract detail to references/details.md (~75 skills)
Apply Anthropic's canonical SKILL.md progressive-disclosure pattern across
the marketplace: SKILL.md body becomes a navigation tier (trigger phrasing
+ quick start), detailed templates and worked examples move to
references/details.md (loaded on demand by the agent).
Motivation: OpenAI Codex CLI hard-truncates skills at 8 KB. Before this
change, ~90 skills exceeded that cap and would silently break on Codex.
The progressive-disclosure pattern is also Anthropic's documented
recommendation for token efficiency — Claude Code reads references/ files
on demand when the body navigation says to.
What's extracted, by pattern:
- Pass 1 (## Templates section): 19 skills — full template libraries
moved to references/details.md
- Pass 2 (## Implementation Patterns / ## Advanced Patterns): 13 skills
- Pass 3 (everything between nav-tier and wrap-tier headings): 53 skills
- Conservative re-extraction for 8 skills that got over-reduced — kept
~6-7 KB inline (most of the quick-start tier) plus references/ overflow
What stays inline (SKILL.md navigation tier):
- description: frontmatter (triggering — unchanged for all skills)
- ## When to Use This Skill / ## Core Concepts / ## Quick Start
- ## Best Practices / ## Troubleshooting / ## See Also wrap-ups
- A pointer note ("see references/details.md") so the agent knows where
to look for detail
What goes to references/details.md (detail tier, on-demand load):
- ## Templates (full code template libraries)
- ## Implementation Patterns / ## Advanced Patterns (deep examples)
- Mid-skill walkthroughs that exceed the inline budget
Also in this commit:
- plugins/brand-landingpage description trimmed from 958→543 chars
(preserves trigger phrasing, drops verbose example-quote list)
Net effect:
- SKILL_OVER_CODEX_CAP findings: 90 → 10 (88% reduction)
- All triggers unchanged — discovery behavior identical across harnesses
- 75 new references/details.md files with the extracted content
- Same depth of guidance, loaded progressively
Remaining 10 oversized skills are complex multi-section docs (e.g.
postgresql, code-review-excellence, evaluation-methodology) that need
per-skill manual judgment — flagged by `make garden` for future work.
* chore: bump all plugin versions (multi-harness release)
Patch-bump every local plugin (81) in both .claude-plugin/marketplace.json
entries and each plugins/<name>/.claude-plugin/plugin.json. Minor-bump the
top-level marketplace metadata.version (1.6.0 → 1.7.0) to signal the
multi-harness adapter framework addition.
The external git-subdir entry (qa-orchestra) is unaffected — its version
is governed by its upstream repo.
* fix(opencode): preserve explicit tools:[] + word-boundary subtask match
Addresses two Codex review findings on PR #541.
## P1 — `tools: []` silently upgraded to permissive (privilege escalation)
Before: `_build_permission_block` returned `{}` for any empty list, which
omits the `permission:` block entirely from the emitted agent. An author
who explicitly wrote `tools: []` to lock down an advisory-only agent got
an UNRESTRICTED agent in OpenCode. Affected agent in this tree:
`plugins/arm-cortex-microcontrollers/agents/arm-cortex-expert.md`.
Fix: `_build_permission_block` now takes a `has_tools_field` flag so the
caller can distinguish "tools: key missing" (Claude default permissive)
from "tools: []" (explicit lock-down). The lock-down case emits a
deny-everything block that allows ONLY the base capabilities (skill, task)
that Claude Code always grants implicitly. Verified against the real
arm-cortex-expert agent — now emits read/edit/write/bash/grep/glob/list:
deny, task/skill: allow.
## P2 — `"agent" in cmd.body.lower()` false-positives on substrings
Before: a command body containing `PerformanceReviewAgent` (class name
in a code snippet) or `useragent` triggered `subtask: true`, changing
runtime behavior based on incidental text.
Fix: switch to a compiled word-boundary regex `\b(agent|subagent)s?\b`
(case-insensitive). Tests confirm the substring `PerformanceReviewAgent`
no longer fires, while a real "spawn a subagent" sentence still does.
## Tests
3 new regression tests in tools/tests/test_adapters.py:
- `test_explicit_empty_tools_yields_locked_permission_block` (P1)
- `test_missing_tools_field_yields_no_permission_block` (P1 boundary)
- `test_subtask_inference_word_boundary` (P2)
184 total tests pass (was 181). OpenCode round-trip still discovers all
193 subagents; arm-cortex-expert agent is now properly locked down.
* test: behavioral verification + CI gates for multi-harness pipeline
Adds three layers of automated verification that pure-Python parser tests
miss, plus the CI jobs that turn them into hard gates. Catches the kinds
of issues that previously only surfaced when a real user installed the
marketplace and tried to use it.
## test_real_world.py — real-source structural tests
Runs against the actual `plugins/` tree (not synthetic fixtures). Catches
issues that only appear on real content:
- every marketplace entry resolves to a plugins/<name>/ dir
- every local plugin dir appears in marketplace.json
- marketplace.json version == per-plugin plugin.json version (catches drift)
- every plugin loads via load_plugin() without error
- no plugin name contains `__` (adapter namespace separator)
- every agent has name + description; every skill has a trigger phrase
(same regex plugin_eval's MISSING_TRIGGER check uses)
- no agent name collides with Codex built-ins
- every refactored skill (with `references/details.md`) has:
- meaningful detail content (>=500 B in details.md)
- a pointer to references/ in the SKILL.md body
- a navigation-tier heading preserved (When to Use, Overview, etc.)
- body >= 600 B (not a stub)
- every plugin.json has name + version matching the dir
This test pass found and fixed three real defects before commit:
- ship-mate/skills/scan: description had no trigger phrase ("Use when…")
- reverse-engineering/skills/memory-forensics: nav-tier section lost
during extraction
- reverse-engineering/skills/binary-analysis-patterns: same
All three are now fixed (preserved trigger phrasing, added When-to-Use
sections back to the skills my extraction over-trimmed).
## test_round_trip.py — generate→parse→verify
CI runs this AFTER `make generate-all`. Catches generation-time regressions:
- OpenCode/Codex/Gemini agent counts match source agent count (no skips)
- every Codex SKILL.md under 8 KB (the cap that would silently truncate)
- every Codex agent TOML has required fields + valid sandbox_mode
- every OpenCode agent has mode in {primary,subagent,all} and
provider-prefixed model
- locked agents (source `tools: []`) emit proper deny-everything permission
block with skill/task allow (regression guard for PR-541 P1)
- every Gemini @{path} injection resolves to a real source file
- every Gemini command TOML has prompt + {{args}} placeholder
- every context file (CLAUDE.md, AGENTS.md, GEMINI.md, etc.) within
150-line cap
- Cursor marketplace + per-plugin manifests cover all local plugins
- .cursor/rules/*.mdc only use the 3 documented frontmatter keys
## test_cli_smoke.py — real-CLI subprocess tests
Invokes the actual harness binaries (OpenCode, Gemini, Codex, Claude Code)
against the generated artifacts. Catches CLI-level issues pure-Python
parsing can't see: schema-loader drift, plugin-discovery bugs, version
incompatibilities.
- `opencode agent list` — must succeed AND discover every source agent
(currently 191 + 2 OpenCode built-ins)
- `gemini extensions validate <repo>` — must return success
- `codex doctor` — must report healthy install
- every Codex agent TOML must parse with stdlib `tomllib`
- `claude --version` — sanity check the Claude Code CLI loads
- marketplace.json must have owner + metadata.version for Claude Code's loader
Per-CLI tests skip gracefully when the binary isn't on PATH, so local
devs only exercise what they have installed. CI installs OpenCode +
Gemini and turns those skips into hard gates.
## Makefile + CI
- `make test` — full pytest suite (plugin-eval + tools/tests/)
- `make smoke-test` — generates if needed, then runs real-CLI smoke tests
- `.github/workflows/validate.yml` extended with:
- `tools-tests` job — runs pytest tools/tests/
- `multi-harness-generate` job — `make generate-all && make validate
STRICT=1 && make garden`, uploads generated artifacts on every run
- `cli-smoke-test` job — installs OpenCode + Gemini, runs test_cli_smoke.py
## Test counts
- Before: 184 tests
- After: 386 tests (parameterized real-source tests over all 82 plugins)
- All passing locally on OpenCode 1.15.7 + Gemini 0.42.0 + Codex 0.133.0
+ Claude Code 2.1.148
561 行
13 KiB
Markdown
561 行
13 KiB
Markdown
# auth-implementation-patterns — detailed patterns and worked examples
|
|
|
|
## JWT Authentication
|
|
|
|
### Pattern 1: JWT Implementation
|
|
|
|
```typescript
|
|
// JWT structure: header.payload.signature
|
|
import jwt from "jsonwebtoken";
|
|
import { Request, Response, NextFunction } from "express";
|
|
|
|
interface JWTPayload {
|
|
userId: string;
|
|
email: string;
|
|
role: string;
|
|
iat: number;
|
|
exp: number;
|
|
}
|
|
|
|
// Generate JWT
|
|
function generateTokens(userId: string, email: string, role: string) {
|
|
const accessToken = jwt.sign(
|
|
{ userId, email, role },
|
|
process.env.JWT_SECRET!,
|
|
{ expiresIn: "15m" }, // Short-lived
|
|
);
|
|
|
|
const refreshToken = jwt.sign(
|
|
{ userId },
|
|
process.env.JWT_REFRESH_SECRET!,
|
|
{ expiresIn: "7d" }, // Long-lived
|
|
);
|
|
|
|
return { accessToken, refreshToken };
|
|
}
|
|
|
|
// Verify JWT
|
|
function verifyToken(token: string): JWTPayload {
|
|
try {
|
|
return jwt.verify(token, process.env.JWT_SECRET!) as JWTPayload;
|
|
} catch (error) {
|
|
if (error instanceof jwt.TokenExpiredError) {
|
|
throw new Error("Token expired");
|
|
}
|
|
if (error instanceof jwt.JsonWebTokenError) {
|
|
throw new Error("Invalid token");
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Middleware
|
|
function authenticate(req: Request, res: Response, next: NextFunction) {
|
|
const authHeader = req.headers.authorization;
|
|
if (!authHeader?.startsWith("Bearer ")) {
|
|
return res.status(401).json({ error: "No token provided" });
|
|
}
|
|
|
|
const token = authHeader.substring(7);
|
|
try {
|
|
const payload = verifyToken(token);
|
|
req.user = payload; // Attach user to request
|
|
next();
|
|
} catch (error) {
|
|
return res.status(401).json({ error: "Invalid token" });
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
app.get("/api/profile", authenticate, (req, res) => {
|
|
res.json({ user: req.user });
|
|
});
|
|
```
|
|
|
|
### Pattern 2: Refresh Token Flow
|
|
|
|
```typescript
|
|
interface StoredRefreshToken {
|
|
token: string;
|
|
userId: string;
|
|
expiresAt: Date;
|
|
createdAt: Date;
|
|
}
|
|
|
|
class RefreshTokenService {
|
|
// Store refresh token in database
|
|
async storeRefreshToken(userId: string, refreshToken: string) {
|
|
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
|
await db.refreshTokens.create({
|
|
token: await hash(refreshToken), // Hash before storing
|
|
userId,
|
|
expiresAt,
|
|
});
|
|
}
|
|
|
|
// Refresh access token
|
|
async refreshAccessToken(refreshToken: string) {
|
|
// Verify refresh token
|
|
let payload;
|
|
try {
|
|
payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET!) as {
|
|
userId: string;
|
|
};
|
|
} catch {
|
|
throw new Error("Invalid refresh token");
|
|
}
|
|
|
|
// Check if token exists in database
|
|
const storedToken = await db.refreshTokens.findOne({
|
|
where: {
|
|
token: await hash(refreshToken),
|
|
userId: payload.userId,
|
|
expiresAt: { $gt: new Date() },
|
|
},
|
|
});
|
|
|
|
if (!storedToken) {
|
|
throw new Error("Refresh token not found or expired");
|
|
}
|
|
|
|
// Get user
|
|
const user = await db.users.findById(payload.userId);
|
|
if (!user) {
|
|
throw new Error("User not found");
|
|
}
|
|
|
|
// Generate new access token
|
|
const accessToken = jwt.sign(
|
|
{ userId: user.id, email: user.email, role: user.role },
|
|
process.env.JWT_SECRET!,
|
|
{ expiresIn: "15m" },
|
|
);
|
|
|
|
return { accessToken };
|
|
}
|
|
|
|
// Revoke refresh token (logout)
|
|
async revokeRefreshToken(refreshToken: string) {
|
|
await db.refreshTokens.deleteOne({
|
|
token: await hash(refreshToken),
|
|
});
|
|
}
|
|
|
|
// Revoke all user tokens (logout all devices)
|
|
async revokeAllUserTokens(userId: string) {
|
|
await db.refreshTokens.deleteMany({ userId });
|
|
}
|
|
}
|
|
|
|
// API endpoints
|
|
app.post("/api/auth/refresh", async (req, res) => {
|
|
const { refreshToken } = req.body;
|
|
try {
|
|
const { accessToken } =
|
|
await refreshTokenService.refreshAccessToken(refreshToken);
|
|
res.json({ accessToken });
|
|
} catch (error) {
|
|
res.status(401).json({ error: "Invalid refresh token" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/auth/logout", authenticate, async (req, res) => {
|
|
const { refreshToken } = req.body;
|
|
await refreshTokenService.revokeRefreshToken(refreshToken);
|
|
res.json({ message: "Logged out successfully" });
|
|
});
|
|
```
|
|
|
|
## Session-Based Authentication
|
|
|
|
### Pattern 1: Express Session
|
|
|
|
```typescript
|
|
import session from "express-session";
|
|
import RedisStore from "connect-redis";
|
|
import { createClient } from "redis";
|
|
|
|
// Setup Redis for session storage
|
|
const redisClient = createClient({
|
|
url: process.env.REDIS_URL,
|
|
});
|
|
await redisClient.connect();
|
|
|
|
app.use(
|
|
session({
|
|
store: new RedisStore({ client: redisClient }),
|
|
secret: process.env.SESSION_SECRET!,
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
cookie: {
|
|
secure: process.env.NODE_ENV === "production", // HTTPS only
|
|
httpOnly: true, // No JavaScript access
|
|
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
|
sameSite: "strict", // CSRF protection
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Login
|
|
app.post("/api/auth/login", async (req, res) => {
|
|
const { email, password } = req.body;
|
|
|
|
const user = await db.users.findOne({ email });
|
|
if (!user || !(await verifyPassword(password, user.passwordHash))) {
|
|
return res.status(401).json({ error: "Invalid credentials" });
|
|
}
|
|
|
|
// Store user in session
|
|
req.session.userId = user.id;
|
|
req.session.role = user.role;
|
|
|
|
res.json({ user: { id: user.id, email: user.email, role: user.role } });
|
|
});
|
|
|
|
// Session middleware
|
|
function requireAuth(req: Request, res: Response, next: NextFunction) {
|
|
if (!req.session.userId) {
|
|
return res.status(401).json({ error: "Not authenticated" });
|
|
}
|
|
next();
|
|
}
|
|
|
|
// Protected route
|
|
app.get("/api/profile", requireAuth, async (req, res) => {
|
|
const user = await db.users.findById(req.session.userId);
|
|
res.json({ user });
|
|
});
|
|
|
|
// Logout
|
|
app.post("/api/auth/logout", (req, res) => {
|
|
req.session.destroy((err) => {
|
|
if (err) {
|
|
return res.status(500).json({ error: "Logout failed" });
|
|
}
|
|
res.clearCookie("connect.sid");
|
|
res.json({ message: "Logged out successfully" });
|
|
});
|
|
});
|
|
```
|
|
|
|
## OAuth2 / Social Login
|
|
|
|
### Pattern 1: OAuth2 with Passport.js
|
|
|
|
```typescript
|
|
import passport from "passport";
|
|
import { Strategy as GoogleStrategy } from "passport-google-oauth20";
|
|
import { Strategy as GitHubStrategy } from "passport-github2";
|
|
|
|
// Google OAuth
|
|
passport.use(
|
|
new GoogleStrategy(
|
|
{
|
|
clientID: process.env.GOOGLE_CLIENT_ID!,
|
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
callbackURL: "/api/auth/google/callback",
|
|
},
|
|
async (accessToken, refreshToken, profile, done) => {
|
|
try {
|
|
// Find or create user
|
|
let user = await db.users.findOne({
|
|
googleId: profile.id,
|
|
});
|
|
|
|
if (!user) {
|
|
user = await db.users.create({
|
|
googleId: profile.id,
|
|
email: profile.emails?.[0]?.value,
|
|
name: profile.displayName,
|
|
avatar: profile.photos?.[0]?.value,
|
|
});
|
|
}
|
|
|
|
return done(null, user);
|
|
} catch (error) {
|
|
return done(error, undefined);
|
|
}
|
|
},
|
|
),
|
|
);
|
|
|
|
// Routes
|
|
app.get(
|
|
"/api/auth/google",
|
|
passport.authenticate("google", {
|
|
scope: ["profile", "email"],
|
|
}),
|
|
);
|
|
|
|
app.get(
|
|
"/api/auth/google/callback",
|
|
passport.authenticate("google", { session: false }),
|
|
(req, res) => {
|
|
// Generate JWT
|
|
const tokens = generateTokens(req.user.id, req.user.email, req.user.role);
|
|
// Redirect to frontend with token
|
|
res.redirect(
|
|
`${process.env.FRONTEND_URL}/auth/callback?token=${tokens.accessToken}`,
|
|
);
|
|
},
|
|
);
|
|
```
|
|
|
|
## Authorization Patterns
|
|
|
|
### Pattern 1: Role-Based Access Control (RBAC)
|
|
|
|
```typescript
|
|
enum Role {
|
|
USER = "user",
|
|
MODERATOR = "moderator",
|
|
ADMIN = "admin",
|
|
}
|
|
|
|
const roleHierarchy: Record<Role, Role[]> = {
|
|
[Role.ADMIN]: [Role.ADMIN, Role.MODERATOR, Role.USER],
|
|
[Role.MODERATOR]: [Role.MODERATOR, Role.USER],
|
|
[Role.USER]: [Role.USER],
|
|
};
|
|
|
|
function hasRole(userRole: Role, requiredRole: Role): boolean {
|
|
return roleHierarchy[userRole].includes(requiredRole);
|
|
}
|
|
|
|
// Middleware
|
|
function requireRole(...roles: Role[]) {
|
|
return (req: Request, res: Response, next: NextFunction) => {
|
|
if (!req.user) {
|
|
return res.status(401).json({ error: "Not authenticated" });
|
|
}
|
|
|
|
if (!roles.some((role) => hasRole(req.user.role, role))) {
|
|
return res.status(403).json({ error: "Insufficient permissions" });
|
|
}
|
|
|
|
next();
|
|
};
|
|
}
|
|
|
|
// Usage
|
|
app.delete(
|
|
"/api/users/:id",
|
|
authenticate,
|
|
requireRole(Role.ADMIN),
|
|
async (req, res) => {
|
|
// Only admins can delete users
|
|
await db.users.delete(req.params.id);
|
|
res.json({ message: "User deleted" });
|
|
},
|
|
);
|
|
```
|
|
|
|
### Pattern 2: Permission-Based Access Control
|
|
|
|
```typescript
|
|
enum Permission {
|
|
READ_USERS = "read:users",
|
|
WRITE_USERS = "write:users",
|
|
DELETE_USERS = "delete:users",
|
|
READ_POSTS = "read:posts",
|
|
WRITE_POSTS = "write:posts",
|
|
}
|
|
|
|
const rolePermissions: Record<Role, Permission[]> = {
|
|
[Role.USER]: [Permission.READ_POSTS, Permission.WRITE_POSTS],
|
|
[Role.MODERATOR]: [
|
|
Permission.READ_POSTS,
|
|
Permission.WRITE_POSTS,
|
|
Permission.READ_USERS,
|
|
],
|
|
[Role.ADMIN]: Object.values(Permission),
|
|
};
|
|
|
|
function hasPermission(userRole: Role, permission: Permission): boolean {
|
|
return rolePermissions[userRole]?.includes(permission) ?? false;
|
|
}
|
|
|
|
function requirePermission(...permissions: Permission[]) {
|
|
return (req: Request, res: Response, next: NextFunction) => {
|
|
if (!req.user) {
|
|
return res.status(401).json({ error: "Not authenticated" });
|
|
}
|
|
|
|
const hasAllPermissions = permissions.every((permission) =>
|
|
hasPermission(req.user.role, permission),
|
|
);
|
|
|
|
if (!hasAllPermissions) {
|
|
return res.status(403).json({ error: "Insufficient permissions" });
|
|
}
|
|
|
|
next();
|
|
};
|
|
}
|
|
|
|
// Usage
|
|
app.get(
|
|
"/api/users",
|
|
authenticate,
|
|
requirePermission(Permission.READ_USERS),
|
|
async (req, res) => {
|
|
const users = await db.users.findAll();
|
|
res.json({ users });
|
|
},
|
|
);
|
|
```
|
|
|
|
### Pattern 3: Resource Ownership
|
|
|
|
```typescript
|
|
// Check if user owns resource
|
|
async function requireOwnership(
|
|
resourceType: "post" | "comment",
|
|
resourceIdParam: string = "id",
|
|
) {
|
|
return async (req: Request, res: Response, next: NextFunction) => {
|
|
if (!req.user) {
|
|
return res.status(401).json({ error: "Not authenticated" });
|
|
}
|
|
|
|
const resourceId = req.params[resourceIdParam];
|
|
|
|
// Admins can access anything
|
|
if (req.user.role === Role.ADMIN) {
|
|
return next();
|
|
}
|
|
|
|
// Check ownership
|
|
let resource;
|
|
if (resourceType === "post") {
|
|
resource = await db.posts.findById(resourceId);
|
|
} else if (resourceType === "comment") {
|
|
resource = await db.comments.findById(resourceId);
|
|
}
|
|
|
|
if (!resource) {
|
|
return res.status(404).json({ error: "Resource not found" });
|
|
}
|
|
|
|
if (resource.userId !== req.user.userId) {
|
|
return res.status(403).json({ error: "Not authorized" });
|
|
}
|
|
|
|
next();
|
|
};
|
|
}
|
|
|
|
// Usage
|
|
app.put(
|
|
"/api/posts/:id",
|
|
authenticate,
|
|
requireOwnership("post"),
|
|
async (req, res) => {
|
|
// User can only update their own posts
|
|
const post = await db.posts.update(req.params.id, req.body);
|
|
res.json({ post });
|
|
},
|
|
);
|
|
```
|
|
|
|
## Security Best Practices
|
|
|
|
### Pattern 1: Password Security
|
|
|
|
```typescript
|
|
import bcrypt from "bcrypt";
|
|
import { z } from "zod";
|
|
|
|
// Password validation schema
|
|
const passwordSchema = z
|
|
.string()
|
|
.min(12, "Password must be at least 12 characters")
|
|
.regex(/[A-Z]/, "Password must contain uppercase letter")
|
|
.regex(/[a-z]/, "Password must contain lowercase letter")
|
|
.regex(/[0-9]/, "Password must contain number")
|
|
.regex(/[^A-Za-z0-9]/, "Password must contain special character");
|
|
|
|
// Hash password
|
|
async function hashPassword(password: string): Promise<string> {
|
|
const saltRounds = 12; // 2^12 iterations
|
|
return bcrypt.hash(password, saltRounds);
|
|
}
|
|
|
|
// Verify password
|
|
async function verifyPassword(
|
|
password: string,
|
|
hash: string,
|
|
): Promise<boolean> {
|
|
return bcrypt.compare(password, hash);
|
|
}
|
|
|
|
// Registration with password validation
|
|
app.post("/api/auth/register", async (req, res) => {
|
|
try {
|
|
const { email, password } = req.body;
|
|
|
|
// Validate password
|
|
passwordSchema.parse(password);
|
|
|
|
// Check if user exists
|
|
const existingUser = await db.users.findOne({ email });
|
|
if (existingUser) {
|
|
return res.status(400).json({ error: "Email already registered" });
|
|
}
|
|
|
|
// Hash password
|
|
const passwordHash = await hashPassword(password);
|
|
|
|
// Create user
|
|
const user = await db.users.create({
|
|
email,
|
|
passwordHash,
|
|
});
|
|
|
|
// Generate tokens
|
|
const tokens = generateTokens(user.id, user.email, user.role);
|
|
|
|
res.status(201).json({
|
|
user: { id: user.id, email: user.email },
|
|
...tokens,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ error: error.errors[0].message });
|
|
}
|
|
res.status(500).json({ error: "Registration failed" });
|
|
}
|
|
});
|
|
```
|
|
|
|
### Pattern 2: Rate Limiting
|
|
|
|
```typescript
|
|
import rateLimit from "express-rate-limit";
|
|
import RedisStore from "rate-limit-redis";
|
|
|
|
// Login rate limiter
|
|
const loginLimiter = rateLimit({
|
|
store: new RedisStore({ client: redisClient }),
|
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
max: 5, // 5 attempts
|
|
message: "Too many login attempts, please try again later",
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
});
|
|
|
|
// API rate limiter
|
|
const apiLimiter = rateLimit({
|
|
windowMs: 60 * 1000, // 1 minute
|
|
max: 100, // 100 requests per minute
|
|
standardHeaders: true,
|
|
});
|
|
|
|
// Apply to routes
|
|
app.post("/api/auth/login", loginLimiter, async (req, res) => {
|
|
// Login logic
|
|
});
|
|
|
|
app.use("/api/", apiLimiter);
|
|
```
|