Browse Source

feat(skills): Add 9 new skills, enhance mcp-ops and tailwind-ops

New skills: react-ops, vue-ops, javascript-ops, astro-ops, laravel-ops,
nginx-ops, auth-ops, monitoring-ops, debug-ops

Enhanced: mcp-ops (426 -> 4,028 lines), tailwind-ops (426 -> 2,969 lines)

Updated catalogs, plugin.json (v1.9.0), README, AGENTS.md, PLAN.md.
Total skills: 59.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
0xDarkMatter 4 months ago
parent
commit
2590b6fa2b
84 changed files with 39348 additions and 677 deletions
  1. 12 3
      .claude-plugin/plugin.json
  2. 14 2
      .claude/settings.local.json
  3. 1 1
      AGENTS.md
  4. 12 3
      README.md
  5. 1 1
      docs/PLAN.md
  6. 386 0
      skills/astro-ops/SKILL.md
  7. 0 0
      skills/astro-ops/assets/.gitkeep
  8. 772 0
      skills/astro-ops/references/content-collections.md
  9. 1035 0
      skills/astro-ops/references/deployment.md
  10. 754 0
      skills/astro-ops/references/islands-rendering.md
  11. 0 0
      skills/astro-ops/scripts/.gitkeep
  12. 325 0
      skills/auth-ops/SKILL.md
  13. 0 0
      skills/auth-ops/assets/.gitkeep
  14. 1018 0
      skills/auth-ops/references/authorization.md
  15. 1230 0
      skills/auth-ops/references/implementation.md
  16. 695 0
      skills/auth-ops/references/jwt-sessions.md
  17. 1014 0
      skills/auth-ops/references/oauth2-oidc.md
  18. 0 0
      skills/auth-ops/scripts/.gitkeep
  19. 277 0
      skills/debug-ops/SKILL.md
  20. 0 0
      skills/debug-ops/assets/.gitkeep
  21. 769 0
      skills/debug-ops/references/common-scenarios.md
  22. 591 0
      skills/debug-ops/references/systematic-methods.md
  23. 934 0
      skills/debug-ops/references/tool-specific.md
  24. 0 0
      skills/debug-ops/scripts/.gitkeep
  25. 281 0
      skills/javascript-ops/SKILL.md
  26. 0 0
      skills/javascript-ops/assets/.gitkeep
  27. 878 0
      skills/javascript-ops/references/async-patterns.md
  28. 757 0
      skills/javascript-ops/references/modern-features.md
  29. 632 0
      skills/javascript-ops/references/modules-runtime.md
  30. 1036 0
      skills/javascript-ops/references/node-patterns.md
  31. 0 0
      skills/javascript-ops/scripts/.gitkeep
  32. 424 0
      skills/laravel-ops/SKILL.md
  33. 0 0
      skills/laravel-ops/assets/.gitkeep
  34. 962 0
      skills/laravel-ops/references/architecture.md
  35. 835 0
      skills/laravel-ops/references/eloquent-queries.md
  36. 1019 0
      skills/laravel-ops/references/testing-auth.md
  37. 0 0
      skills/laravel-ops/scripts/.gitkeep
  38. 280 109
      skills/mcp-ops/SKILL.md
  39. 0 50
      skills/mcp-ops/references/auth-patterns.md
  40. 0 48
      skills/mcp-ops/references/resource-patterns.md
  41. 560 0
      skills/mcp-ops/references/resources-prompts.md
  42. 703 0
      skills/mcp-ops/references/server-architecture.md
  43. 0 57
      skills/mcp-ops/references/state-patterns.md
  44. 868 0
      skills/mcp-ops/references/testing-debugging.md
  45. 0 39
      skills/mcp-ops/references/testing-patterns.md
  46. 847 0
      skills/mcp-ops/references/tool-handlers.md
  47. 0 88
      skills/mcp-ops/references/tool-patterns.md
  48. 735 0
      skills/mcp-ops/references/transport-auth.md
  49. 333 0
      skills/monitoring-ops/SKILL.md
  50. 0 0
      skills/monitoring-ops/assets/.gitkeep
  51. 1031 0
      skills/monitoring-ops/references/infrastructure.md
  52. 1036 0
      skills/monitoring-ops/references/logging.md
  53. 1078 0
      skills/monitoring-ops/references/metrics-alerting.md
  54. 996 0
      skills/monitoring-ops/references/tracing.md
  55. 0 0
      skills/monitoring-ops/scripts/.gitkeep
  56. 469 0
      skills/nginx-ops/SKILL.md
  57. 0 0
      skills/nginx-ops/assets/.gitkeep
  58. 951 0
      skills/nginx-ops/references/performance.md
  59. 1091 0
      skills/nginx-ops/references/reverse-proxy.md
  60. 998 0
      skills/nginx-ops/references/ssl-security.md
  61. 0 0
      skills/nginx-ops/scripts/.gitkeep
  62. 272 0
      skills/react-ops/SKILL.md
  63. 0 0
      skills/react-ops/assets/.gitkeep
  64. 661 0
      skills/react-ops/references/component-architecture.md
  65. 788 0
      skills/react-ops/references/hooks-patterns.md
  66. 650 0
      skills/react-ops/references/performance.md
  67. 451 0
      skills/react-ops/references/server-components.md
  68. 647 0
      skills/react-ops/references/state-management.md
  69. 519 0
      skills/react-ops/references/testing.md
  70. 0 0
      skills/react-ops/scripts/.gitkeep
  71. 425 61
      skills/tailwind-ops/SKILL.md
  72. 965 206
      skills/tailwind-ops/references/component-patterns.md
  73. 913 0
      skills/tailwind-ops/references/configuration.md
  74. 507 0
      skills/tailwind-ops/references/v4-migration.md
  75. 17 2
      skills/tool-discovery/SKILL.md
  76. 40 0
      skills/tool-discovery/references/agents-catalog.md
  77. 170 7
      skills/tool-discovery/references/skills-catalog.md
  78. 436 0
      skills/vue-ops/SKILL.md
  79. 0 0
      skills/vue-ops/assets/.gitkeep
  80. 837 0
      skills/vue-ops/references/composition-api.md
  81. 845 0
      skills/vue-ops/references/nuxt.md
  82. 718 0
      skills/vue-ops/references/state-routing.md
  83. 847 0
      skills/vue-ops/references/testing.md
  84. 0 0
      skills/vue-ops/scripts/.gitkeep

+ 12 - 3
.claude-plugin/plugin.json

@@ -1,7 +1,7 @@
 {
   "name": "claude-mods",
-  "version": "1.8.0",
-  "description": "Custom commands, skills, and agents for Claude Code - session continuity, 22 expert agents, 50 skills, 3 commands, 5 rules, modern CLI tools",
+  "version": "1.9.0",
+  "description": "Custom commands, skills, and agents for Claude Code - session continuity, 22 expert agents, 59 skills, 3 commands, 5 rules, modern CLI tools",
   "author": "0xDarkMatter",
   "repository": "https://github.com/0xDarkMatter/claude-mods",
   "license": "MIT",
@@ -48,7 +48,9 @@
     ],
     "skills": [
       "skills/api-design-ops",
+      "skills/astro-ops",
       "skills/atomise",
+      "skills/auth-ops",
       "skills/ci-cd-ops",
       "skills/claude-code-debug",
       "skills/claude-code-headless",
@@ -59,6 +61,7 @@
       "skills/container-orchestration",
       "skills/data-processing",
       "skills/doc-scanner",
+      "skills/debug-ops",
       "skills/docker-ops",
       "skills/explain",
       "skills/file-search",
@@ -66,8 +69,12 @@
       "skills/git-workflow",
       "skills/go-ops",
       "skills/introspect",
+      "skills/javascript-ops",
+      "skills/laravel-ops",
       "skills/markitdown",
       "skills/mcp-ops",
+      "skills/monitoring-ops",
+      "skills/nginx-ops",
       "skills/postgres-ops",
       "skills/project-planner",
       "skills/python-async-ops",
@@ -78,6 +85,7 @@
       "skills/python-observability-ops",
       "skills/python-pytest-ops",
       "skills/python-typing-ops",
+      "skills/react-ops",
       "skills/rest-ops",
       "skills/review",
       "skills/rust-ops",
@@ -96,7 +104,8 @@
       "skills/testgen",
       "skills/tool-discovery",
       "skills/typescript-ops",
-      "skills/unfold-admin"
+      "skills/unfold-admin",
+      "skills/vue-ops"
     ],
     "rules": [
       "rules/cli-tools.md",

+ 14 - 2
.claude/settings.local.json

@@ -1,6 +1,7 @@
 {
   "env": {
-    "ENABLE_TOOL_SEARCH": "true"
+    "ENABLE_TOOL_SEARCH": "true",
+    "ENABLE_LSP_TOOL": "true"
   },
   "permissions": {
     "allow": [
@@ -141,7 +142,18 @@
       "Bash(scoop:*)",
       "Bash(winget:*)",
       "Bash(powershell -ExecutionPolicy Bypass -File \"./scripts/install.ps1\")",
-      "Bash(just.exe test:*)"
+      "Bash(just.exe test:*)",
+      "Bash(Select-String \"Results:\")",
+      "Bash(for skill in atomise cli-patterns explain screenshot setperms skill-creator spawn techdebt)",
+      "Bash(do cp -r \"X:/Forge/claude-mods/skills/$skill\" \"C:/Users/Mack/.claude/skills/$skill\")",
+      "Bash(done)",
+      "Bash(do basename:*)",
+      "Bash(/tmp/skill_analysis.txt:*)",
+      "Read(//tmp/**)",
+      "Bash(do mkdir:*)",
+      "Bash(for skill in go-ops rust-ops typescript-ops docker-ops ci-cd-ops api-design-ops)",
+      "Bash(do echo \"=== $skill ===\")",
+      "Read(//x/Forge/claude-mods/skills/$skill/**)"
     ],
     "deny": [],
     "ask": [

+ 1 - 1
AGENTS.md

@@ -5,7 +5,7 @@
 This is **claude-mods** - a collection of custom extensions for Claude Code:
 - **22 expert agents** for specialized domains (React, Python, Go, Rust, AWS, etc.)
 - **3 commands** for session management (/sync, /save) and experimental features (/canvas)
-- **50 skills** for CLI tools, patterns, workflows, and development tasks
+- **59 skills** for CLI tools, patterns, workflows, and development tasks
 - **Custom output styles** for response personality (e.g., Vesper)
 
 ## Installation

+ 12 - 3
README.md

@@ -18,7 +18,7 @@ Built on [Anthropic's Agent Skills standard](https://github.com/anthropics/skill
 
 From Python async patterns to Rust ownership models, from AWS Fargate deployments to Craft CMS development - claude-mods provides the specialized knowledge and tools that transform Claude from a general-purpose assistant into a domain expert who understands your stack, remembers your workflow, and ships production code.
 
-**22 agents. 50 skills. 3 commands. One install.**
+**22 agents. 59 skills. 3 commands. One install.**
 
 ## Recent Updates
 
@@ -173,8 +173,13 @@ See [skill-creator](skills/skill-creator/) for the complete guide.
 | [go-ops](skills/go-ops/) | Go concurrency, error handling, testing, interfaces, generics, project structure |
 | [rust-ops](skills/rust-ops/) | Rust ownership, async/tokio, error handling, traits, serde, ecosystem |
 | [typescript-ops](skills/typescript-ops/) | TypeScript type system, generics, utility types, strict mode, Zod |
+| [javascript-ops](skills/javascript-ops/) | JavaScript/Node.js async patterns, modules, ES2024+, runtime internals |
+| [react-ops](skills/react-ops/) | React hooks, Server Components, state management, performance, testing |
+| [vue-ops](skills/vue-ops/) | Vue 3 Composition API, Pinia, Vue Router, Nuxt 3 |
+| [astro-ops](skills/astro-ops/) | Astro islands, content collections, rendering strategies, deployment |
+| [laravel-ops](skills/laravel-ops/) | Laravel Eloquent, architecture, authentication, testing with Pest |
 | [cli-ops](skills/cli-ops/) | Production CLI tool patterns - agentic workflows, stream separation, exit codes |
-| [tailwind-ops](skills/tailwind-ops/) | Tailwind utilities, responsive breakpoints, config |
+| [tailwind-ops](skills/tailwind-ops/) | Tailwind CSS patterns, v4 migration, components, configuration |
 
 #### Data & API Skills
 | Skill | Description |
@@ -184,7 +189,7 @@ See [skill-creator](skills/skill-creator/) for the complete guide.
 | [sql-ops](skills/sql-ops/) | CTEs, window functions, JOIN patterns, indexing |
 | [postgres-ops](skills/postgres-ops/) | PostgreSQL operations, optimization, schema design, replication, monitoring |
 | [sqlite-ops](skills/sqlite-ops/) | SQLite schemas, Python sqlite3/aiosqlite patterns |
-| [mcp-ops](skills/mcp-ops/) | MCP server structure, tool handlers, resources |
+| [mcp-ops](skills/mcp-ops/) | MCP server development, FastMCP, transports, tool design, testing |
 
 #### Infrastructure Skills
 | Skill | Description |
@@ -192,6 +197,10 @@ See [skill-creator](skills/skill-creator/) for the complete guide.
 | [docker-ops](skills/docker-ops/) | Dockerfile best practices, multi-stage builds, Compose, optimization |
 | [ci-cd-ops](skills/ci-cd-ops/) | GitHub Actions, release automation, testing pipelines |
 | [container-orchestration](skills/container-orchestration/) | Kubernetes, Helm, pod patterns |
+| [nginx-ops](skills/nginx-ops/) | Nginx reverse proxy, SSL/TLS, load balancing, performance tuning |
+| [auth-ops](skills/auth-ops/) | JWT, OAuth2, sessions, RBAC/ABAC, passkeys, MFA |
+| [monitoring-ops](skills/monitoring-ops/) | Prometheus, Grafana, OpenTelemetry, structured logging, alerting |
+| [debug-ops](skills/debug-ops/) | Systematic debugging, language-specific debuggers, common scenarios |
 
 #### CLI Tool Skills
 | Skill | Description |

+ 1 - 1
docs/PLAN.md

@@ -13,7 +13,7 @@
 | Component | Count | Notes |
 |-----------|-------|-------|
 | Agents | 22 | Domain experts (Python, Go, Rust, React, etc.) |
-| Skills | 50 | Operational skills, CLI tools, workflows, dev tasks |
+| Skills | 59 | Operational skills, CLI tools, workflows, dev tasks |
 | Commands | 3 | Session management (sync, save) + experimental (canvas) |
 | Rules | 5 | CLI tools, thinking, commit style, naming, skill-agent-updates |
 | Output Styles | 1 | Vesper personality |

+ 386 - 0
skills/astro-ops/SKILL.md

@@ -0,0 +1,386 @@
+---
+name: astro-ops
+description: "Astro framework patterns, islands architecture, content collections, rendering strategies, and deployment. Use for: astro, islands architecture, content collections, astro cloudflare, view transitions, partial hydration, astrojs, SSG, SSR, hybrid rendering, astro adapter."
+allowed-tools: "Read Write Bash"
+related-skills: [typescript-ops, tailwind-ops, javascript-ops]
+---
+
+# Astro Operations
+
+Comprehensive patterns for Astro framework development: islands architecture, content collections, rendering strategies, view transitions, and multi-platform deployment.
+
+## Rendering Strategy Decision Tree
+
+```
+Which rendering strategy?
+│
+├─ Is content mostly static (blog, docs, marketing)?
+│  ├─ YES → Does it change less than daily?
+│  │  ├─ YES → SSG (output: 'static')
+│  │  │        Fastest TTFB, CDN-cacheable, zero runtime cost
+│  │  └─ NO  → Hybrid (output: 'hybrid')
+│  │           Default static + opt-in SSR per route
+│  └─ NO  → Does every page need personalization?
+│     ├─ YES → SSR (output: 'server')
+│     │        Dynamic per-request, auth-aware, real-time data
+│     └─ NO  → Hybrid (output: 'hybrid')
+│              Static shell + server islands for dynamic parts
+│
+├─ Does the app need real-time interactivity (dashboard, SPA)?
+│  ├─ YES → Is it a full SPA with client-side routing?
+│  │  ├─ YES → Consider React/Vue SPA instead, or Astro + client:only
+│  │  └─ NO  → Hybrid + islands architecture
+│  │           Interactive islands in static pages
+│  └─ NO  → SSG (output: 'static')
+│
+├─ Build time concerns (>10k pages)?
+│  ├─ YES → Hybrid with on-demand rendering
+│  │        Prerender popular pages, SSR the long tail
+│  └─ NO  → SSG handles it fine
+│
+└─ Need edge computing (low latency globally)?
+   ├─ YES → SSR + Cloudflare/Vercel Edge adapter
+   └─ NO  → SSR + Node adapter or SSG
+```
+
+### Configuration
+
+```typescript
+// astro.config.mjs
+import { defineConfig } from 'astro/config';
+
+// SSG (default) - all pages prerendered at build time
+export default defineConfig({
+  output: 'static',
+});
+
+// SSR - all pages rendered on request
+export default defineConfig({
+  output: 'server',
+  adapter: cloudflare(), // or vercel(), netlify(), node()
+});
+
+// Hybrid - static default, opt-in SSR per page
+export default defineConfig({
+  output: 'hybrid',
+  adapter: cloudflare(),
+});
+```
+
+```astro
+---
+// In hybrid mode, opt OUT of prerendering for specific pages:
+export const prerender = false;
+// In SSR mode, opt IN to prerendering:
+export const prerender = true;
+---
+```
+
+## Islands Architecture Quick Reference
+
+| Directive | Hydrates When | JS Shipped | Use Case |
+|-----------|--------------|------------|----------|
+| `client:load` | Immediately on page load | Full bundle | Above-fold interactive (nav, hero CTA) |
+| `client:idle` | After page is idle (`requestIdleCallback`) | Full bundle | Below-fold interactive (comment form, chat) |
+| `client:visible` | When scrolled into viewport | Full bundle | Far-down-page (footer widget, carousel) |
+| `client:media` | When media query matches | Full bundle | Mobile-only nav, responsive components |
+| `client:only="react"` | Immediately, skip SSR entirely | Full bundle | Components that can't SSR (canvas, WebGL) |
+| (none) | Never - static HTML only | Zero JS | Static content, cards, headers |
+
+```astro
+---
+import NavBar from '../components/NavBar.tsx';
+import CommentForm from '../components/CommentForm.tsx';
+import ImageCarousel from '../components/ImageCarousel.svelte';
+import MobileMenu from '../components/MobileMenu.vue';
+import ThreeScene from '../components/ThreeScene.tsx';
+---
+
+<!-- Loads immediately - critical interactivity -->
+<NavBar client:load />
+
+<!-- Loads after page is idle - non-critical -->
+<CommentForm client:idle />
+
+<!-- Loads when scrolled into view - lazy -->
+<ImageCarousel client:visible />
+
+<!-- Loads only on mobile -->
+<MobileMenu client:media="(max-width: 768px)" />
+
+<!-- Client-only, no SSR (WebGL can't run on server) -->
+<ThreeScene client:only="react" />
+```
+
+## Content Collections Quick Start
+
+### Define Schema
+
+```typescript
+// src/content.config.ts (Astro 5) or src/content/config.ts (Astro 4)
+import { defineCollection, z, reference } from 'astro:content';
+import { glob } from 'astro/loaders';
+
+const blog = defineCollection({
+  loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
+  schema: z.object({
+    title: z.string(),
+    description: z.string().max(160),
+    pubDate: z.coerce.date(),
+    updatedDate: z.coerce.date().optional(),
+    heroImage: z.string().optional(),
+    tags: z.array(z.string()).default([]),
+    draft: z.boolean().default(false),
+    author: reference('authors'), // Reference another collection
+  }),
+});
+
+const authors = defineCollection({
+  loader: glob({ pattern: '**/*.json', base: './src/content/authors' }),
+  schema: z.object({
+    name: z.string(),
+    avatar: z.string(),
+    bio: z.string(),
+    socials: z.object({
+      twitter: z.string().optional(),
+      github: z.string().optional(),
+    }).optional(),
+  }),
+});
+
+export const collections = { blog, authors };
+```
+
+### Query Collections
+
+```astro
+---
+import { getCollection, getEntry } from 'astro:content';
+
+// Get all non-draft blog posts, sorted by date
+const posts = (await getCollection('blog', ({ data }) => !data.draft))
+  .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
+
+// Get a single entry
+const post = await getEntry('blog', 'my-first-post');
+
+// Resolve a reference
+const author = await getEntry(post.data.author);
+
+// Render content
+const { Content, headings } = await post.render();
+---
+
+<Content />
+```
+
+## Project Structure Reference
+
+```
+project-root/
+├── astro.config.mjs          # Astro configuration
+├── tsconfig.json              # TypeScript config (extends astro/tsconfigs)
+├── package.json
+├── public/                    # Static assets (copied as-is)
+│   ├── favicon.svg
+│   ├── robots.txt
+│   └── og-image.png
+├── src/
+│   ├── pages/                 # File-based routing
+│   │   ├── index.astro        # → /
+│   │   ├── about.astro        # → /about
+│   │   ├── blog/
+│   │   │   ├── index.astro    # → /blog
+│   │   │   └── [slug].astro   # → /blog/:slug (dynamic)
+│   │   ├── api/
+│   │   │   └── search.ts      # → /api/search (API endpoint)
+│   │   └── [...slug].astro    # → catch-all/404
+│   ├── layouts/
+│   │   ├── BaseLayout.astro   # HTML shell, <head>, global styles
+│   │   └── BlogPost.astro     # Blog post layout
+│   ├── components/
+│   │   ├── Header.astro       # Static Astro component
+│   │   ├── Footer.astro
+│   │   ├── NavBar.tsx         # React island
+│   │   └── Counter.svelte     # Svelte island
+│   ├── content/               # Content collections source files
+│   │   ├── blog/
+│   │   │   ├── post-one.md
+│   │   │   └── post-two.mdx
+│   │   └── authors/
+│   │       └── jane.json
+│   ├── content.config.ts      # Collection schemas (Astro 5)
+│   ├── middleware.ts           # Request/response middleware
+│   ├── styles/
+│   │   └── global.css
+│   └── lib/                   # Shared utilities
+│       ├── utils.ts
+│       └── constants.ts
+└── .env                       # Environment variables
+```
+
+## View Transitions Quick Reference
+
+```astro
+---
+// src/layouts/BaseLayout.astro
+import { ViewTransitions } from 'astro:transitions';
+---
+
+<html>
+  <head>
+    <ViewTransitions />
+  </head>
+  <body>
+    <slot />
+  </body>
+</html>
+```
+
+### Transition Directives
+
+```astro
+<!-- Persist element across pages (keeps state, avoids re-render) -->
+<audio transition:persist id="player">
+  <source src="/music.mp3" />
+</audio>
+
+<!-- Named transition for animation pairing -->
+<img transition:name="hero" src={post.heroImage} />
+
+<!-- Custom animation -->
+<div transition:animate="slide">Content</div>
+<div transition:animate="fade">Content</div>
+<div transition:animate="none">No animation</div>
+
+<!-- Persist with name (for multiple persistent elements) -->
+<video transition:persist="media-player" />
+```
+
+### Lifecycle Events
+
+```astro
+<script>
+  document.addEventListener('astro:before-preparation', (e) => {
+    // Before new page is fetched - cancel navigation, show loading
+  });
+
+  document.addEventListener('astro:after-preparation', (e) => {
+    // New page fetched, before swap
+  });
+
+  document.addEventListener('astro:before-swap', (e) => {
+    // Customize DOM swap behavior
+  });
+
+  document.addEventListener('astro:after-swap', () => {
+    // DOM updated - reinitialize scripts
+  });
+
+  document.addEventListener('astro:page-load', () => {
+    // Page fully loaded (fires on initial + every navigation)
+    // Use this instead of DOMContentLoaded with View Transitions
+  });
+</script>
+```
+
+### Back/Forward Handling
+
+```typescript
+// astro.config.mjs
+export default defineConfig({
+  prefetch: {
+    prefetchAll: true,         // Prefetch all links on hover
+    defaultStrategy: 'hover',  // 'hover' | 'tap' | 'viewport' | 'load'
+  },
+});
+```
+
+```astro
+<!-- Per-link prefetch control -->
+<a href="/about" data-astro-prefetch>Prefetch on hover (default)</a>
+<a href="/blog" data-astro-prefetch="viewport">Prefetch when visible</a>
+<a href="/contact" data-astro-prefetch="load">Prefetch immediately</a>
+<a href="/external" data-astro-prefetch="false">No prefetch</a>
+```
+
+## Deployment Decision Tree
+
+```
+Where to deploy?
+│
+├─ Need edge computing + Cloudflare ecosystem (KV, D1, R2)?
+│  └─ Cloudflare Pages/Workers
+│     Adapter: @astrojs/cloudflare
+│     Best for: Global edge, Workers bindings, cost-effective
+│
+├─ Need serverless + Vercel ecosystem (ISR, analytics)?
+│  └─ Vercel
+│     Adapter: @astrojs/vercel
+│     Best for: Next.js migration, image optimization, ISR
+│
+├─ Need serverless + Netlify ecosystem (forms, identity)?
+│  └─ Netlify
+│     Adapter: @astrojs/netlify
+│     Best for: JAMstack, built-in forms, split testing
+│
+├─ Need full server control (Docker, custom runtime)?
+│  └─ Node.js (standalone or Express/Fastify)
+│     Adapter: @astrojs/node
+│     Best for: Self-hosted, WebSocket, long-running processes
+│
+└─ Pure static site (no SSR needed)?
+   └─ Any static host (GitHub Pages, S3, Cloudflare Pages)
+      No adapter needed, output: 'static'
+      Best for: Blogs, docs, marketing sites
+```
+
+### Adapter Installation
+
+```bash
+# Cloudflare
+npx astro add cloudflare
+
+# Vercel
+npx astro add vercel
+
+# Netlify
+npx astro add netlify
+
+# Node.js
+npx astro add node
+```
+
+## Common Gotchas
+
+| Gotcha | Why | Fix |
+|--------|-----|-----|
+| Hydration mismatch errors | Server HTML differs from client render (dates, random IDs, browser APIs) | Use `client:only` for browser-dependent components, or ensure deterministic rendering |
+| `import.meta.env` undefined in client | Only `PUBLIC_` prefixed vars are exposed to client-side code | Rename to `PUBLIC_MY_VAR` or pass via props from server |
+| Dynamic routes 404 in SSG | `getStaticPaths()` not returning all possible params | Ensure `getStaticPaths()` returns every valid path, or switch to hybrid/SSR |
+| Images not optimizing | Using `<img>` instead of Astro's `<Image />` component | Import from `astro:assets`: `import { Image } from 'astro:assets'` and use local imports for src |
+| SSR fails without adapter | `output: 'server'` or `'hybrid'` requires a deployment adapter | Install adapter: `npx astro add cloudflare` (or vercel, netlify, node) |
+| MDX components not rendering | Custom components not passed to MDX content | Pass components via `<Content components={{ MyComponent }} />` or use `astro.config.mjs` MDX config |
+| Content collection schema changes not reflected | Type generation is cached, stale `.astro` types | Run `astro sync` to regenerate types, restart dev server |
+| `client:*` on Astro components | Client directives only work on framework components (React, Vue, Svelte) | Astro components are static-only; extract interactive parts to a framework component |
+| `document` / `window` is not defined | Server-side code cannot access browser globals | Guard with `if (typeof window !== 'undefined')` or move to `client:only` |
+| Styles leaking between components | Using global CSS instead of scoped styles | Use `<style>` (scoped by default in .astro) or `<style is:global>` intentionally |
+| View Transitions break scripts | `DOMContentLoaded` only fires once with View Transitions | Use `astro:page-load` event instead, which fires on every navigation |
+| Env vars missing in production | `.env` not loaded or platform env vars not configured | Use `envField` in astro.config.mjs for validation; set vars in platform dashboard |
+
+## Reference Files
+
+| File | Contents | Lines |
+|------|----------|-------|
+| `references/content-collections.md` | Schema patterns, Zod types, querying, MDX, content layer API, migrations | ~500 |
+| `references/islands-rendering.md` | Islands deep dive, client directives, framework integration, server islands | ~550 |
+| `references/deployment.md` | Cloudflare/Vercel/Netlify/Node adapters, env vars, optimization | ~500 |
+
+## See Also
+
+- **typescript-ops** - TypeScript patterns used throughout Astro projects
+- **tailwind-ops** - Tailwind CSS integration with Astro (`@astrojs/tailwind`)
+- **javascript-ops** - Core JS patterns for client-side island code
+- **container-orchestration** - Docker patterns for self-hosted Astro (Node adapter)
+- [Astro Documentation](https://docs.astro.build)
+- [Astro Integration Guide](https://docs.astro.build/en/guides/integrations-guide/)

+ 0 - 0
skills/astro-ops/assets/.gitkeep


+ 772 - 0
skills/astro-ops/references/content-collections.md

@@ -0,0 +1,772 @@
+# Content Collections Reference
+
+Comprehensive guide to Astro content collections: schema definition, querying, references, MDX integration, and the Content Layer API.
+
+## Schema Definition with Zod
+
+### Basic Schema
+
+```typescript
+// src/content.config.ts (Astro 5+)
+import { defineCollection, z } from 'astro:content';
+import { glob, file } from 'astro/loaders';
+
+const blog = defineCollection({
+  loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
+  schema: z.object({
+    title: z.string(),
+    description: z.string(),
+    pubDate: z.coerce.date(),
+    draft: z.boolean().default(false),
+  }),
+});
+
+export const collections = { blog };
+```
+
+### All Supported Zod Types
+
+```typescript
+import { defineCollection, z, reference } from 'astro:content';
+import { glob, file } from 'astro/loaders';
+
+const fullSchema = defineCollection({
+  loader: glob({ pattern: '**/*.mdx', base: './src/content/posts' }),
+  schema: ({ image }) => z.object({
+    // String types
+    title: z.string(),
+    slug: z.string().optional(),
+    description: z.string().max(160),
+    canonical: z.string().url().optional(),
+
+    // Number types
+    readingTime: z.number().positive().optional(),
+    order: z.number().int().min(0).default(0),
+
+    // Date types
+    pubDate: z.coerce.date(),                    // Accepts string or Date
+    updatedDate: z.coerce.date().optional(),
+
+    // Boolean
+    draft: z.boolean().default(false),
+    featured: z.boolean().default(false),
+
+    // Enum
+    category: z.enum(['tutorial', 'guide', 'reference', 'blog']),
+    status: z.enum(['draft', 'review', 'published']).default('draft'),
+
+    // Arrays
+    tags: z.array(z.string()).default([]),
+    relatedSlugs: z.array(z.string()).optional(),
+
+    // Nested objects
+    author: z.object({
+      name: z.string(),
+      email: z.string().email().optional(),
+    }),
+
+    // Union types
+    layout: z.union([
+      z.literal('default'),
+      z.literal('wide'),
+      z.literal('full'),
+    ]).default('default'),
+
+    // Image (validated by Astro, returns optimized metadata)
+    heroImage: image().optional(),
+    thumbnail: image().refine((img) => img.width >= 200, {
+      message: 'Thumbnail must be at least 200px wide',
+    }).optional(),
+
+    // References to other collections
+    author_ref: reference('authors'),
+    relatedPosts: z.array(reference('blog')).default([]),
+
+    // Custom transforms
+    title_normalized: z.string().transform((val) => val.toLowerCase().trim()),
+
+    // Passthrough for unknown fields
+    // extra: z.record(z.unknown()),
+  }),
+});
+```
+
+### Image Schema
+
+```typescript
+// The image() helper validates that referenced images exist at build time
+const gallery = defineCollection({
+  loader: glob({ pattern: '**/*.md', base: './src/content/gallery' }),
+  schema: ({ image }) => z.object({
+    title: z.string(),
+    cover: image(),
+    // Refine with dimension constraints
+    hero: image().refine((img) => img.width >= 1080, {
+      message: 'Hero image must be at least 1080px wide',
+    }),
+    // Array of images
+    photos: z.array(image()).default([]),
+  }),
+});
+```
+
+Usage in frontmatter:
+
+```markdown
+---
+title: My Gallery
+cover: ./images/cover.jpg        # Relative path to image
+hero: ../../assets/hero.png      # Can reference shared assets
+photos:
+  - ./images/photo1.jpg
+  - ./images/photo2.jpg
+---
+```
+
+## References Between Collections
+
+### Defining References
+
+```typescript
+// src/content.config.ts
+import { defineCollection, z, reference } from 'astro:content';
+import { glob, file } from 'astro/loaders';
+
+const authors = defineCollection({
+  loader: glob({ pattern: '**/*.json', base: './src/content/authors' }),
+  schema: z.object({
+    name: z.string(),
+    avatar: z.string(),
+    bio: z.string(),
+    website: z.string().url().optional(),
+  }),
+});
+
+const categories = defineCollection({
+  loader: file('src/data/categories.json'),
+  schema: z.object({
+    name: z.string(),
+    slug: z.string(),
+    description: z.string(),
+  }),
+});
+
+const blog = defineCollection({
+  loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
+  schema: z.object({
+    title: z.string(),
+    // Single reference
+    author: reference('authors'),
+    // Optional reference
+    reviewer: reference('authors').optional(),
+    // Array of references
+    categories: z.array(reference('categories')).default([]),
+    // Self-reference (same collection)
+    relatedPosts: z.array(reference('blog')).default([]),
+  }),
+});
+
+export const collections = { authors, categories, blog };
+```
+
+### Blog post frontmatter with references
+
+```markdown
+---
+title: Getting Started with Astro
+author: jane-doe
+reviewer: john-smith
+categories:
+  - tutorials
+  - astro
+relatedPosts:
+  - advanced-astro-patterns
+  - astro-vs-next
+---
+```
+
+### Resolving References
+
+```astro
+---
+import { getEntry, getCollection } from 'astro:content';
+
+// Get the blog post
+const post = await getEntry('blog', 'getting-started');
+
+// Resolve single reference
+const author = await getEntry(post.data.author);
+// author.data.name, author.data.avatar, etc.
+
+// Resolve optional reference
+const reviewer = post.data.reviewer
+  ? await getEntry(post.data.reviewer)
+  : null;
+
+// Resolve array of references
+const categories = await Promise.all(
+  post.data.categories.map((ref) => getEntry(ref))
+);
+
+// Resolve self-references
+const relatedPosts = await Promise.all(
+  post.data.relatedPosts.map((ref) => getEntry(ref))
+);
+---
+
+<article>
+  <h1>{post.data.title}</h1>
+  <p>By {author.data.name}</p>
+  {reviewer && <p>Reviewed by {reviewer.data.name}</p>}
+  <div class="categories">
+    {categories.map((cat) => <span>{cat.data.name}</span>)}
+  </div>
+</article>
+```
+
+## Querying Collections
+
+### getCollection
+
+```typescript
+import { getCollection } from 'astro:content';
+
+// Get all entries
+const allPosts = await getCollection('blog');
+
+// Filter with callback (type-safe)
+const publishedPosts = await getCollection('blog', ({ data }) => {
+  return !data.draft && data.pubDate <= new Date();
+});
+
+// Sort by date (descending)
+const sortedPosts = (await getCollection('blog'))
+  .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
+
+// Filter by tag
+const astroTagged = await getCollection('blog', ({ data }) => {
+  return data.tags.includes('astro');
+});
+
+// Paginate
+const pageSize = 10;
+const page = 1;
+const paginatedPosts = sortedPosts.slice(
+  (page - 1) * pageSize,
+  page * pageSize
+);
+```
+
+### getEntry
+
+```typescript
+import { getEntry } from 'astro:content';
+
+// Get single entry by collection + id
+const post = await getEntry('blog', 'my-first-post');
+
+// Returns null if not found (in Astro 5, throws if not found by default)
+if (!post) {
+  return Astro.redirect('/404');
+}
+
+// Access data
+console.log(post.data.title);   // Type-safe frontmatter
+console.log(post.id);           // Entry ID (filename without extension)
+
+// Render content
+const { Content, headings, remarkPluginFrontmatter } = await post.render();
+```
+
+### Dynamic Routes with Collections
+
+```astro
+---
+// src/pages/blog/[slug].astro
+import { getCollection, render } from 'astro:content';
+
+export async function getStaticPaths() {
+  const posts = await getCollection('blog', ({ data }) => !data.draft);
+  return posts.map((post) => ({
+    params: { slug: post.id },
+    props: { post },
+  }));
+}
+
+const { post } = Astro.props;
+const { Content, headings } = await render(post);
+---
+
+<article>
+  <h1>{post.data.title}</h1>
+  <time datetime={post.data.pubDate.toISOString()}>
+    {post.data.pubDate.toLocaleDateString()}
+  </time>
+  <Content />
+</article>
+```
+
+### Pagination with Collections
+
+```astro
+---
+// src/pages/blog/[...page].astro
+import type { GetStaticPaths } from 'astro';
+import { getCollection } from 'astro:content';
+
+export const getStaticPaths = (async ({ paginate }) => {
+  const posts = (await getCollection('blog', ({ data }) => !data.draft))
+    .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
+
+  return paginate(posts, { pageSize: 10 });
+}) satisfies GetStaticPaths;
+
+const { page } = Astro.props;
+// page.data       - current page entries
+// page.currentPage - current page number
+// page.lastPage   - total pages
+// page.url.prev   - previous page URL
+// page.url.next   - next page URL
+// page.total      - total entries
+---
+
+{page.data.map((post) => (
+  <article>
+    <a href={`/blog/${post.id}`}>{post.data.title}</a>
+  </article>
+))}
+
+<nav>
+  {page.url.prev && <a href={page.url.prev}>Previous</a>}
+  <span>Page {page.currentPage} of {page.lastPage}</span>
+  {page.url.next && <a href={page.url.next}>Next</a>}
+</nav>
+```
+
+## MDX Integration
+
+### Setup
+
+```bash
+npx astro add mdx
+```
+
+```typescript
+// astro.config.mjs
+import { defineConfig } from 'astro/config';
+import mdx from '@astrojs/mdx';
+
+export default defineConfig({
+  integrations: [mdx()],
+});
+```
+
+### Custom Components in MDX
+
+```mdx
+---
+title: Interactive Tutorial
+---
+
+import Counter from '../../components/Counter.tsx';
+import Callout from '../../components/Callout.astro';
+import { Code } from 'astro:components';
+
+# {frontmatter.title}
+
+Here's a live counter:
+
+<Counter client:visible initialCount={5} />
+
+<Callout type="warning">
+  Remember to hydrate interactive components with a `client:*` directive!
+</Callout>
+```
+
+### Passing Components to Rendered Content
+
+```astro
+---
+import { getEntry, render } from 'astro:content';
+import Callout from '../components/Callout.astro';
+import CodeBlock from '../components/CodeBlock.astro';
+
+const post = await getEntry('blog', 'my-post');
+const { Content } = await render(post);
+---
+
+<!-- Override default HTML elements with custom components -->
+<Content components={{
+  h1: 'h2',                    <!-- Remap h1 to h2 -->
+  blockquote: Callout,         <!-- Replace blockquotes with Callout -->
+  pre: CodeBlock,              <!-- Replace code blocks -->
+}} />
+```
+
+### Remark and Rehype Plugins
+
+```typescript
+// astro.config.mjs
+import { defineConfig } from 'astro/config';
+import mdx from '@astrojs/mdx';
+import remarkToc from 'remark-toc';
+import remarkMath from 'remark-math';
+import rehypeKatex from 'rehype-katex';
+import rehypeSlug from 'rehype-slug';
+import rehypeAutolinkHeadings from 'rehype-autolink-headings';
+
+export default defineConfig({
+  integrations: [mdx()],
+  markdown: {
+    remarkPlugins: [
+      remarkToc,
+      remarkMath,
+    ],
+    rehypePlugins: [
+      rehypeSlug,
+      [rehypeAutolinkHeadings, { behavior: 'wrap' }],
+      rehypeKatex,
+    ],
+    // Syntax highlighting
+    shikiConfig: {
+      theme: 'github-dark',
+      wrap: true,
+    },
+  },
+});
+```
+
+## Content Layer API (Astro 5)
+
+The Content Layer API replaces the filesystem-coupled collection system with a flexible loader-based approach.
+
+### Built-in Loaders
+
+```typescript
+// src/content.config.ts
+import { defineCollection, z } from 'astro:content';
+import { glob, file } from 'astro/loaders';
+
+// Glob loader - load from filesystem with glob patterns
+const blog = defineCollection({
+  loader: glob({
+    pattern: '**/*.{md,mdx}',
+    base: './src/content/blog',
+    // Optional: generate ID from filename
+    generateId: ({ entry, base, data }) => {
+      return entry.replace(/\.mdx?$/, '');
+    },
+  }),
+  schema: z.object({
+    title: z.string(),
+    pubDate: z.coerce.date(),
+  }),
+});
+
+// File loader - load from a single JSON/YAML file
+const navigation = defineCollection({
+  loader: file('src/data/navigation.json'),
+  schema: z.object({
+    label: z.string(),
+    href: z.string(),
+    order: z.number(),
+  }),
+});
+
+// File loader with nested data
+const settings = defineCollection({
+  loader: file('src/data/settings.yaml', {
+    // Extract array from nested path
+    parser: (text) => {
+      const yaml = parseYaml(text);
+      return yaml.site.menuItems;
+    },
+  }),
+  schema: z.object({
+    label: z.string(),
+    url: z.string(),
+  }),
+});
+
+export const collections = { blog, navigation, settings };
+```
+
+### Custom Loaders
+
+```typescript
+// src/loaders/api-loader.ts
+import type { Loader } from 'astro/loaders';
+
+export function apiLoader(options: { url: string; apiKey: string }): Loader {
+  return {
+    name: 'api-loader',
+    load: async ({ store, logger, parseData, generateDigest }) => {
+      logger.info('Fetching data from API...');
+
+      const response = await fetch(options.url, {
+        headers: { Authorization: `Bearer ${options.apiKey}` },
+      });
+      const items = await response.json();
+
+      // Clear previous data
+      store.clear();
+
+      for (const item of items) {
+        const digest = generateDigest(item);
+
+        // Parse and validate data against schema
+        const data = await parseData({
+          id: String(item.id),
+          data: item,
+        });
+
+        store.set({
+          id: String(item.id),
+          data,
+          digest,
+          // Optional: rendered HTML content
+          rendered: {
+            html: item.content_html ?? '',
+          },
+        });
+      }
+
+      logger.info(`Loaded ${items.length} items`);
+    },
+  };
+}
+```
+
+```typescript
+// src/content.config.ts
+import { defineCollection, z } from 'astro:content';
+import { apiLoader } from '../loaders/api-loader';
+
+const products = defineCollection({
+  loader: apiLoader({
+    url: 'https://api.example.com/products',
+    apiKey: import.meta.env.API_KEY,
+  }),
+  schema: z.object({
+    name: z.string(),
+    price: z.number(),
+    description: z.string(),
+    inStock: z.boolean(),
+  }),
+});
+
+export const collections = { products };
+```
+
+### CMS Integration Loaders
+
+```typescript
+// Example: Notion loader (community package)
+import { notionLoader } from '@notionhq/astro-loader';
+
+const docs = defineCollection({
+  loader: notionLoader({
+    databaseId: import.meta.env.NOTION_DB_ID,
+    auth: import.meta.env.NOTION_API_KEY,
+  }),
+  schema: z.object({
+    title: z.string(),
+    status: z.enum(['Draft', 'Published']),
+    lastEdited: z.coerce.date(),
+  }),
+});
+```
+
+### Incremental Builds
+
+```typescript
+// Custom loader with incremental update support
+export function incrementalLoader(options: { url: string }): Loader {
+  return {
+    name: 'incremental-loader',
+    load: async ({ store, logger, parseData, meta }) => {
+      // meta.store persists between builds
+      const lastSync = meta.get('lastSync');
+
+      const url = lastSync
+        ? `${options.url}?since=${lastSync}`
+        : options.url;
+
+      const response = await fetch(url);
+      const items = await response.json();
+
+      // Only update changed items (don't clear store)
+      for (const item of items) {
+        if (item.deleted) {
+          store.delete(String(item.id));
+        } else {
+          const data = await parseData({
+            id: String(item.id),
+            data: item,
+          });
+          store.set({ id: String(item.id), data });
+        }
+      }
+
+      meta.set('lastSync', new Date().toISOString());
+    },
+  };
+}
+```
+
+## Type Generation and InferEntrySchema
+
+### Generating Types
+
+```bash
+# Manually regenerate types after schema changes
+npx astro sync
+```
+
+### Using InferEntrySchema
+
+```typescript
+// src/lib/types.ts
+import type { InferEntrySchema, CollectionEntry } from 'astro:content';
+
+// Infer the schema type for a collection
+type BlogPost = InferEntrySchema<'blog'>;
+// { title: string; description: string; pubDate: Date; ... }
+
+// Full collection entry type (includes id, data, render, etc.)
+type BlogEntry = CollectionEntry<'blog'>;
+
+// Use in utility functions
+function formatPost(post: CollectionEntry<'blog'>) {
+  return {
+    title: post.data.title,
+    url: `/blog/${post.id}`,
+    date: post.data.pubDate.toLocaleDateString(),
+  };
+}
+
+// Use in component props
+interface PostListProps {
+  posts: CollectionEntry<'blog'>[];
+  showDrafts?: boolean;
+}
+```
+
+### Type-safe Frontmatter in Layouts
+
+```astro
+---
+// src/layouts/BlogPost.astro
+import type { CollectionEntry } from 'astro:content';
+
+interface Props {
+  post: CollectionEntry<'blog'>;
+}
+
+const { post } = Astro.props;
+const { title, description, pubDate, heroImage, author } = post.data;
+---
+
+<html>
+  <head>
+    <title>{title}</title>
+    <meta name="description" content={description} />
+  </head>
+  <body>
+    <article>
+      <h1>{title}</h1>
+      <time datetime={pubDate.toISOString()}>
+        {pubDate.toLocaleDateString('en-US', {
+          year: 'numeric', month: 'long', day: 'numeric'
+        })}
+      </time>
+      <slot />
+    </article>
+  </body>
+</html>
+```
+
+## Migration Guide: Astro 2/3/4 to Astro 5
+
+### Collection Config Location
+
+```
+# Astro 4 (legacy)
+src/content/config.ts
+
+# Astro 5 (Content Layer API)
+src/content.config.ts        # Note: moved to src root
+```
+
+### Adding Loaders (Required in Astro 5)
+
+```typescript
+// Astro 4 - implicit filesystem loading
+const blog = defineCollection({
+  type: 'content',               // Remove this
+  schema: z.object({ ... }),
+});
+
+// Astro 5 - explicit loaders
+import { glob, file } from 'astro/loaders';
+
+const blog = defineCollection({
+  loader: glob({                  // Add loader
+    pattern: '**/*.{md,mdx}',
+    base: './src/content/blog',
+  }),
+  schema: z.object({ ... }),
+});
+```
+
+### Data Collections Migration
+
+```typescript
+// Astro 4 - data collections
+const authors = defineCollection({
+  type: 'data',                   // Remove this
+  schema: z.object({ ... }),
+});
+
+// Astro 5 - use file loader
+import { file } from 'astro/loaders';
+
+const authors = defineCollection({
+  loader: file('src/data/authors.json'),  // Or glob for multiple files
+  schema: z.object({ ... }),
+});
+```
+
+### Entry ID Changes
+
+```typescript
+// Astro 4
+post.slug;           // Used for routing
+post.id;             // Included file extension: "my-post.md"
+
+// Astro 5
+post.id;             // Slug-like, no extension: "my-post"
+// post.slug removed - use post.id instead
+```
+
+### Rendering Changes
+
+```typescript
+// Astro 4
+const { Content } = await post.render();
+
+// Astro 5
+import { render } from 'astro:content';
+const { Content } = await render(post);
+```
+
+### Checklist for Migration
+
+1. Move `src/content/config.ts` to `src/content.config.ts`
+2. Add `loader` property to every collection (use `glob()` or `file()`)
+3. Remove `type: 'content'` and `type: 'data'` from collections
+4. Replace `post.slug` with `post.id` in routing
+5. Replace `post.render()` with `render(post)` from `astro:content`
+6. Run `npx astro sync` to regenerate types
+7. Update `getStaticPaths()` to use `post.id` instead of `post.slug`
+8. Test all content pages and dynamic routes

+ 1035 - 0
skills/astro-ops/references/deployment.md

@@ -0,0 +1,1035 @@
+# Deployment Reference
+
+Comprehensive guide to deploying Astro applications across platforms: Cloudflare, Vercel, Netlify, Node.js, and static hosting.
+
+## Cloudflare Workers / Pages
+
+### Setup
+
+```bash
+npx astro add cloudflare
+```
+
+```typescript
+// astro.config.mjs
+import { defineConfig } from 'astro/config';
+import cloudflare from '@astrojs/cloudflare';
+
+export default defineConfig({
+  output: 'server',           // or 'hybrid'
+  adapter: cloudflare({
+    imageService: 'cloudflare', // Use Cloudflare Image Resizing
+    platformProxy: {
+      enabled: true,           // Enable local bindings in dev
+    },
+  }),
+  site: 'https://example.com',
+});
+```
+
+### Wrangler Configuration
+
+```toml
+# wrangler.toml
+name = "my-astro-site"
+compatibility_date = "2024-11-01"
+compatibility_flags = ["nodejs_compat"]
+pages_build_output_dir = "./dist"
+
+# KV Namespace binding
+[[kv_namespaces]]
+binding = "CACHE"
+id = "abc123"
+
+# D1 Database binding
+[[d1_databases]]
+binding = "DB"
+database_name = "my-db"
+database_id = "def456"
+
+# R2 Bucket binding
+[[r2_buckets]]
+binding = "ASSETS"
+bucket_name = "my-assets"
+
+# Environment variables
+[vars]
+API_URL = "https://api.example.com"
+
+# Secrets (set via wrangler secret put)
+# SECRET_KEY - set via `wrangler secret put SECRET_KEY`
+```
+
+### Accessing Cloudflare Bindings
+
+```typescript
+// Type definitions for Cloudflare bindings
+// src/env.d.ts
+/// <reference types="astro/client" />
+
+type Runtime = import('@astrojs/cloudflare').Runtime<Env>;
+
+interface Env {
+  CACHE: KVNamespace;
+  DB: D1Database;
+  ASSETS: R2Bucket;
+  API_URL: string;
+  SECRET_KEY: string;
+}
+
+declare namespace App {
+  interface Locals extends Runtime {}
+}
+```
+
+```astro
+---
+// src/pages/api/data.ts
+import type { APIContext } from 'astro';
+
+export async function GET({ locals }: APIContext) {
+  const { env } = locals.runtime;
+
+  // KV operations
+  const cached = await env.CACHE.get('my-key');
+  if (cached) {
+    return new Response(cached, {
+      headers: { 'Content-Type': 'application/json' },
+    });
+  }
+
+  // D1 database query
+  const { results } = await env.DB
+    .prepare('SELECT * FROM posts WHERE published = ?')
+    .bind(true)
+    .all();
+
+  // R2 object storage
+  const object = await env.ASSETS.get('images/hero.jpg');
+
+  // Cache the result
+  await env.CACHE.put('my-key', JSON.stringify(results), {
+    expirationTtl: 3600,
+  });
+
+  return new Response(JSON.stringify(results), {
+    headers: { 'Content-Type': 'application/json' },
+  });
+}
+```
+
+### Cloudflare Middleware
+
+```typescript
+// src/middleware.ts
+import { defineMiddleware } from 'astro:middleware';
+
+export const onRequest = defineMiddleware(async ({ locals, request, cookies }, next) => {
+  const { env } = locals.runtime;
+
+  // Auth check using KV
+  const session = cookies.get('session')?.value;
+  if (session) {
+    const user = await env.CACHE.get(`session:${session}`);
+    if (user) {
+      locals.user = JSON.parse(user);
+    }
+  }
+
+  // Rate limiting with KV
+  const ip = request.headers.get('CF-Connecting-IP') ?? 'unknown';
+  const rateKey = `rate:${ip}`;
+  const count = parseInt(await env.CACHE.get(rateKey) ?? '0');
+
+  if (count > 100) {
+    return new Response('Rate limited', { status: 429 });
+  }
+
+  await env.CACHE.put(rateKey, String(count + 1), { expirationTtl: 60 });
+
+  return next();
+});
+```
+
+### Deployment
+
+```bash
+# Build and deploy to Cloudflare Pages
+npm run build
+npx wrangler pages deploy dist
+
+# Or connect to Git for automatic deploys via Cloudflare Dashboard
+# Settings > Build > Framework preset: Astro
+```
+
+## Vercel
+
+### Setup
+
+```bash
+npx astro add vercel
+```
+
+```typescript
+// astro.config.mjs
+import { defineConfig } from 'astro/config';
+import vercel from '@astrojs/vercel';
+
+export default defineConfig({
+  output: 'server',           // or 'hybrid'
+  adapter: vercel({
+    imageService: true,        // Use Vercel Image Optimization
+    isr: {
+      expiration: 60,          // ISR: revalidate every 60 seconds
+    },
+    webAnalytics: {
+      enabled: true,           // Enable Vercel Web Analytics
+    },
+    maxDuration: 30,           // Serverless function timeout (seconds)
+  }),
+});
+```
+
+### Serverless vs Edge
+
+```typescript
+// Default: serverless function
+export default defineConfig({
+  output: 'server',
+  adapter: vercel(),
+});
+
+// Edge function (faster cold start, limited APIs)
+export default defineConfig({
+  output: 'server',
+  adapter: vercel({
+    edgeMiddleware: true,      // Run middleware at the edge
+  }),
+});
+```
+
+### ISR (Incremental Static Regeneration)
+
+```astro
+---
+// Per-page ISR configuration
+// src/pages/blog/[slug].astro
+export const prerender = false;
+
+// Set ISR headers
+Astro.response.headers.set(
+  'Cache-Control',
+  's-maxage=60, stale-while-revalidate=600'
+);
+---
+```
+
+### Vercel Environment Variables
+
+```bash
+# Set via Vercel CLI
+vercel env add PRIVATE_KEY
+vercel env add PUBLIC_API_URL
+
+# Or via vercel.json
+```
+
+```json
+// vercel.json
+{
+  "framework": "astro",
+  "buildCommand": "astro build",
+  "outputDirectory": "dist",
+  "headers": [
+    {
+      "source": "/api/(.*)",
+      "headers": [
+        { "key": "Cache-Control", "value": "s-maxage=60" }
+      ]
+    }
+  ],
+  "redirects": [
+    { "source": "/old-path", "destination": "/new-path", "permanent": true }
+  ]
+}
+```
+
+### Deployment
+
+```bash
+# Deploy to Vercel
+npx vercel
+
+# Production deploy
+npx vercel --prod
+
+# Or connect to Git for automatic deploys
+```
+
+## Netlify
+
+### Setup
+
+```bash
+npx astro add netlify
+```
+
+```typescript
+// astro.config.mjs
+import { defineConfig } from 'astro/config';
+import netlify from '@astrojs/netlify';
+
+export default defineConfig({
+  output: 'server',           // or 'hybrid'
+  adapter: netlify({
+    edgeMiddleware: true,      // Run middleware at the edge
+    imageCDN: true,            // Use Netlify Image CDN
+  }),
+});
+```
+
+### Netlify Configuration
+
+```toml
+# netlify.toml
+[build]
+  command = "astro build"
+  publish = "dist"
+
+[build.environment]
+  NODE_VERSION = "20"
+
+# Redirects
+[[redirects]]
+  from = "/old-path"
+  to = "/new-path"
+  status = 301
+
+# Custom headers
+[[headers]]
+  for = "/api/*"
+  [headers.values]
+    Access-Control-Allow-Origin = "*"
+    Cache-Control = "public, max-age=60"
+
+# Netlify Forms
+# Forms are auto-detected in static builds
+# For SSR, use Netlify Forms API
+```
+
+### Netlify Edge Functions
+
+```typescript
+// netlify/edge-functions/geolocation.ts
+import type { Context } from '@netlify/edge-functions';
+
+export default async function (request: Request, context: Context) {
+  const { country, city } = context.geo;
+
+  // Add geo data to request headers for Astro middleware
+  request.headers.set('x-country', country?.code ?? 'US');
+  request.headers.set('x-city', city ?? 'Unknown');
+
+  return context.next();
+}
+
+export const config = { path: '/*' };
+```
+
+### Netlify Forms with Astro
+
+```astro
+---
+// Static output - Netlify auto-detects forms
+---
+
+<form name="contact" method="POST" data-netlify="true">
+  <input type="hidden" name="form-name" value="contact" />
+  <input type="text" name="name" required />
+  <input type="email" name="email" required />
+  <textarea name="message" required></textarea>
+  <button type="submit">Send</button>
+</form>
+```
+
+### Deployment
+
+```bash
+# Deploy to Netlify
+npx netlify deploy
+
+# Production deploy
+npx netlify deploy --prod
+
+# Or connect to Git for automatic deploys
+```
+
+## Node.js (Self-hosted)
+
+### Setup
+
+```bash
+npx astro add node
+```
+
+```typescript
+// astro.config.mjs
+import { defineConfig } from 'astro/config';
+import node from '@astrojs/node';
+
+export default defineConfig({
+  output: 'server',
+  adapter: node({
+    mode: 'standalone',        // or 'middleware'
+  }),
+});
+```
+
+### Standalone Mode
+
+```bash
+# Build
+npm run build
+
+# Run (starts built-in HTTP server)
+HOST=0.0.0.0 PORT=4321 node dist/server/entry.mjs
+```
+
+### Middleware Mode (Express/Fastify)
+
+```typescript
+// astro.config.mjs
+import node from '@astrojs/node';
+
+export default defineConfig({
+  output: 'server',
+  adapter: node({ mode: 'middleware' }),
+});
+```
+
+```typescript
+// server.mjs - Custom Express server
+import express from 'express';
+import { handler as astroHandler } from './dist/server/entry.mjs';
+
+const app = express();
+
+// Custom middleware before Astro
+app.use('/health', (req, res) => {
+  res.json({ status: 'ok' });
+});
+
+// Serve static files
+app.use(express.static('dist/client'));
+
+// Astro handles everything else
+app.use(astroHandler);
+
+const port = process.env.PORT || 4321;
+app.listen(port, () => {
+  console.log(`Server running on port ${port}`);
+});
+```
+
+```typescript
+// server-fastify.mjs - Custom Fastify server
+import Fastify from 'fastify';
+import fastifyStatic from '@fastify/static';
+import { handler as astroHandler } from './dist/server/entry.mjs';
+import { fileURLToPath } from 'url';
+import path from 'path';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+const app = Fastify({ logger: true });
+
+// Static files
+app.register(fastifyStatic, {
+  root: path.join(__dirname, 'dist/client'),
+});
+
+// Health check
+app.get('/health', async () => ({ status: 'ok' }));
+
+// Astro handler
+app.use(astroHandler);
+
+app.listen({ port: 4321, host: '0.0.0.0' });
+```
+
+### Docker Deployment
+
+```dockerfile
+# Dockerfile
+FROM node:20-slim AS builder
+
+WORKDIR /app
+COPY package*.json ./
+RUN npm ci
+
+COPY . .
+RUN npm run build
+
+FROM node:20-slim AS runtime
+
+WORKDIR /app
+COPY --from=builder /app/dist ./dist
+COPY --from=builder /app/node_modules ./node_modules
+COPY --from=builder /app/package.json ./
+
+ENV HOST=0.0.0.0
+ENV PORT=4321
+
+EXPOSE 4321
+
+HEALTHCHECK --interval=30s --timeout=3s \
+  CMD curl -f http://localhost:4321/health || exit 1
+
+CMD ["node", "dist/server/entry.mjs"]
+```
+
+```yaml
+# docker-compose.yml
+services:
+  astro:
+    build: .
+    ports:
+      - "4321:4321"
+    environment:
+      - DATABASE_URL=postgres://db:5432/app
+      - SECRET_KEY=${SECRET_KEY}
+    restart: unless-stopped
+    depends_on:
+      - db
+  db:
+    image: postgres:16-alpine
+    volumes:
+      - pgdata:/var/lib/postgresql/data
+    environment:
+      POSTGRES_DB: app
+      POSTGRES_PASSWORD: ${DB_PASSWORD}
+
+volumes:
+  pgdata:
+```
+
+## Static Hosting
+
+### Configuration
+
+```typescript
+// astro.config.mjs
+import { defineConfig } from 'astro/config';
+
+export default defineConfig({
+  output: 'static',           // Default - all pages prerendered
+  site: 'https://example.com',
+  base: '/my-app',            // If hosted at a subpath
+});
+```
+
+### GitHub Pages
+
+```yaml
+# .github/workflows/deploy.yml
+name: Deploy to GitHub Pages
+
+on:
+  push:
+    branches: [main]
+
+permissions:
+  contents: read
+  pages: write
+  id-token: write
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-node@v4
+        with:
+          node-version: 20
+      - run: npm ci
+      - run: npm run build
+      - uses: actions/upload-pages-artifact@v3
+        with:
+          path: dist
+
+  deploy:
+    needs: build
+    runs-on: ubuntu-latest
+    environment:
+      name: github-pages
+      url: ${{ steps.deployment.outputs.page_url }}
+    steps:
+      - id: deployment
+        uses: actions/deploy-pages@v4
+```
+
+```typescript
+// astro.config.mjs for GitHub Pages
+export default defineConfig({
+  site: 'https://username.github.io',
+  base: '/repo-name',         // For project pages (not needed for user pages)
+});
+```
+
+### S3 + CloudFront
+
+```bash
+# Build and sync to S3
+npm run build
+aws s3 sync dist s3://my-bucket --delete
+aws cloudfront create-invalidation --distribution-id DIST_ID --paths "/*"
+```
+
+## Environment Variables
+
+### Astro Environment Variable Rules
+
+```
+# .env
+# Private (server-only) - NOT available in client-side code
+DATABASE_URL=postgres://localhost:5432/mydb
+API_SECRET=sk-12345
+SESSION_KEY=abc
+
+# Public (available in client-side code) - MUST start with PUBLIC_
+PUBLIC_API_URL=https://api.example.com
+PUBLIC_SITE_NAME=My Site
+PUBLIC_GA_ID=G-12345
+```
+
+### Accessing Environment Variables
+
+```typescript
+// Server-side (pages, middleware, API routes, server islands)
+const dbUrl = import.meta.env.DATABASE_URL;        // Works
+const apiKey = import.meta.env.API_SECRET;          // Works
+const publicUrl = import.meta.env.PUBLIC_API_URL;   // Works
+
+// Client-side (browser, client:* components)
+const publicUrl = import.meta.env.PUBLIC_API_URL;   // Works
+const dbUrl = import.meta.env.DATABASE_URL;         // undefined!
+```
+
+### envField Schema Validation (Astro 5)
+
+```typescript
+// astro.config.mjs
+import { defineConfig, envField } from 'astro/config';
+
+export default defineConfig({
+  env: {
+    schema: {
+      // Server-only variables
+      DATABASE_URL: envField.string({
+        context: 'server',
+        access: 'secret',
+        optional: false,
+      }),
+      API_KEY: envField.string({
+        context: 'server',
+        access: 'secret',
+      }),
+      PORT: envField.number({
+        context: 'server',
+        access: 'public',
+        default: 4321,
+      }),
+
+      // Client-accessible variables
+      PUBLIC_API_URL: envField.string({
+        context: 'client',
+        access: 'public',
+      }),
+      PUBLIC_FEATURE_FLAG: envField.boolean({
+        context: 'client',
+        access: 'public',
+        default: false,
+      }),
+    },
+  },
+});
+```
+
+```typescript
+// Type-safe env access with validation
+import { DATABASE_URL, PORT } from 'astro:env/server';
+import { PUBLIC_API_URL, PUBLIC_FEATURE_FLAG } from 'astro:env/client';
+
+// These are typed and validated at build time
+console.log(DATABASE_URL);     // string (required)
+console.log(PORT);             // number (defaults to 4321)
+console.log(PUBLIC_API_URL);   // string (required)
+```
+
+### Platform-specific Environment Variables
+
+```bash
+# Cloudflare - set in wrangler.toml or dashboard
+wrangler secret put API_KEY
+
+# Vercel
+vercel env add API_KEY production
+
+# Netlify
+netlify env:set API_KEY "value"
+
+# Docker
+docker run -e DATABASE_URL=... my-astro-app
+```
+
+## Headers and Redirects
+
+### Middleware-based Headers
+
+```typescript
+// src/middleware.ts
+import { defineMiddleware, sequence } from 'astro:middleware';
+
+const securityHeaders = defineMiddleware(async (context, next) => {
+  const response = await next();
+
+  response.headers.set('X-Content-Type-Options', 'nosniff');
+  response.headers.set('X-Frame-Options', 'DENY');
+  response.headers.set('X-XSS-Protection', '1; mode=block');
+  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
+  response.headers.set(
+    'Content-Security-Policy',
+    "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
+  );
+
+  return response;
+});
+
+const cacheHeaders = defineMiddleware(async (context, next) => {
+  const response = await next();
+
+  // Cache static assets aggressively
+  if (context.url.pathname.startsWith('/_astro/')) {
+    response.headers.set('Cache-Control', 'public, max-age=31536000, immutable');
+  }
+
+  return response;
+});
+
+export const onRequest = sequence(securityHeaders, cacheHeaders);
+```
+
+### Static File Headers
+
+```
+# public/_headers (Cloudflare Pages / Netlify)
+/*
+  X-Content-Type-Options: nosniff
+  X-Frame-Options: DENY
+
+/_astro/*
+  Cache-Control: public, max-age=31536000, immutable
+
+/api/*
+  Cache-Control: no-cache
+  Access-Control-Allow-Origin: *
+```
+
+### Redirects
+
+```
+# public/_redirects (Cloudflare Pages / Netlify)
+/old-blog/*    /blog/:splat    301
+/legacy        /               302
+/docs          https://docs.example.com  301
+```
+
+```typescript
+// Programmatic redirects in middleware
+import { defineMiddleware } from 'astro:middleware';
+
+const redirects: Record<string, { to: string; status: 301 | 302 }> = {
+  '/old-path': { to: '/new-path', status: 301 },
+  '/legacy': { to: '/', status: 302 },
+};
+
+export const onRequest = defineMiddleware(async ({ url, redirect }, next) => {
+  const rule = redirects[url.pathname];
+  if (rule) {
+    return redirect(rule.to, rule.status);
+  }
+  return next();
+});
+```
+
+## SSR Streaming
+
+### Response Streaming
+
+```astro
+---
+// Astro streams HTML by default in SSR mode
+// Components render top-to-bottom, streaming chunks to the client
+
+// Slow data fetch - page header already visible while this loads
+const slowData = await fetch('https://slow-api.example.com/data')
+  .then(r => r.json());
+---
+
+<html>
+  <body>
+    <!-- This streams immediately -->
+    <h1>Page Title</h1>
+
+    <!-- This streams after slowData resolves -->
+    <div>{slowData.content}</div>
+  </body>
+</html>
+```
+
+### Streaming with Server Islands
+
+```astro
+---
+// Combine streaming with server islands for optimal loading
+import SlowWidget from '../components/SlowWidget.astro';
+import UserDashboard from '../components/UserDashboard.astro';
+---
+
+<!-- Streams immediately -->
+<header>Fast static content</header>
+
+<!-- Server island: page sends without waiting for this -->
+<SlowWidget server:defer>
+  <div slot="fallback">Loading widget...</div>
+</SlowWidget>
+
+<!-- This also streams immediately (doesn't wait for SlowWidget) -->
+<main>More fast content</main>
+
+<UserDashboard server:defer>
+  <div slot="fallback" class="skeleton">Loading dashboard...</div>
+</UserDashboard>
+```
+
+## Build Optimization
+
+### Bundle Analysis
+
+```typescript
+// astro.config.mjs
+import { defineConfig } from 'astro/config';
+
+export default defineConfig({
+  vite: {
+    build: {
+      // Analyze bundle
+      rollupOptions: {
+        output: {
+          manualChunks: {
+            // Group vendor chunks
+            'react-vendor': ['react', 'react-dom'],
+            'utils': ['date-fns', 'lodash-es'],
+          },
+        },
+      },
+    },
+  },
+});
+```
+
+### Prefetch Strategies
+
+```typescript
+// astro.config.mjs
+export default defineConfig({
+  prefetch: {
+    // Prefetch links on hover (default for View Transitions)
+    defaultStrategy: 'hover',
+
+    // Or be more aggressive
+    // defaultStrategy: 'viewport',  // Prefetch when link enters viewport
+    // defaultStrategy: 'load',      // Prefetch all links on page load
+
+    // Prefetch all same-origin links
+    prefetchAll: false,
+  },
+});
+```
+
+```astro
+<!-- Per-link prefetch control -->
+<a href="/about" data-astro-prefetch="hover">Hover to prefetch</a>
+<a href="/important" data-astro-prefetch="viewport">Prefetch when visible</a>
+<a href="/critical" data-astro-prefetch="load">Prefetch immediately</a>
+<a href="/external" data-astro-prefetch="false">Never prefetch</a>
+```
+
+### Image Service Configuration
+
+```typescript
+// astro.config.mjs
+import { defineConfig } from 'astro/config';
+
+export default defineConfig({
+  image: {
+    // Use Sharp (default, best quality)
+    service: { entrypoint: 'astro/assets/services/sharp' },
+
+    // Remote image domains
+    domains: ['cdn.example.com', 'images.unsplash.com'],
+
+    // Remote patterns (more granular)
+    remotePatterns: [
+      {
+        protocol: 'https',
+        hostname: '**.example.com',
+        pathname: '/images/**',
+      },
+    ],
+  },
+});
+```
+
+```astro
+---
+import { Image, Picture } from 'astro:assets';
+import heroImage from '../assets/hero.jpg';
+---
+
+<!-- Optimized image with automatic format conversion -->
+<Image
+  src={heroImage}
+  alt="Hero image"
+  width={1200}
+  height={600}
+  quality={80}
+  format="avif"
+  loading="eager"             <!-- Above fold: eager, below fold: lazy (default) -->
+/>
+
+<!-- Responsive picture with multiple formats -->
+<Picture
+  src={heroImage}
+  formats={['avif', 'webp']}
+  widths={[400, 800, 1200]}
+  sizes="(max-width: 768px) 100vw, 1200px"
+  alt="Responsive hero"
+/>
+
+<!-- Remote image (must be in domains/remotePatterns) -->
+<Image
+  src="https://cdn.example.com/photo.jpg"
+  alt="Remote image"
+  width={800}
+  height={400}
+  inferSize                   <!-- Auto-detect dimensions -->
+/>
+```
+
+### Compression and Performance
+
+```typescript
+// astro.config.mjs
+import { defineConfig } from 'astro/config';
+import compress from 'astro-compress';
+
+export default defineConfig({
+  integrations: [
+    compress({
+      CSS: true,
+      HTML: true,
+      Image: true,
+      JavaScript: true,
+      SVG: true,
+    }),
+  ],
+  compressHTML: true,          // Built-in HTML minification
+  build: {
+    inlineStylesheets: 'auto', // Inline small CSS (<4KB)
+  },
+  vite: {
+    build: {
+      cssMinify: 'lightningcss',  // Faster CSS minification
+    },
+  },
+});
+```
+
+### Sitemap and SEO
+
+```bash
+npx astro add sitemap
+```
+
+```typescript
+// astro.config.mjs
+import sitemap from '@astrojs/sitemap';
+
+export default defineConfig({
+  site: 'https://example.com',
+  integrations: [
+    sitemap({
+      filter: (page) => !page.includes('/admin'),
+      changefreq: 'weekly',
+      priority: 0.7,
+      lastmod: new Date(),
+      i18n: {
+        defaultLocale: 'en',
+        locales: {
+          en: 'en-US',
+          es: 'es-ES',
+        },
+      },
+    }),
+  ],
+});
+```
+
+```astro
+---
+// src/layouts/BaseLayout.astro - SEO head tags
+interface Props {
+  title: string;
+  description: string;
+  image?: string;
+  canonicalURL?: string;
+}
+
+const {
+  title,
+  description,
+  image = '/og-default.png',
+  canonicalURL = Astro.url.href,
+} = Astro.props;
+---
+
+<html>
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <title>{title}</title>
+    <meta name="description" content={description} />
+    <link rel="canonical" href={canonicalURL} />
+
+    <!-- Open Graph -->
+    <meta property="og:title" content={title} />
+    <meta property="og:description" content={description} />
+    <meta property="og:image" content={new URL(image, Astro.site)} />
+    <meta property="og:url" content={canonicalURL} />
+    <meta property="og:type" content="website" />
+
+    <!-- Twitter -->
+    <meta name="twitter:card" content="summary_large_image" />
+    <meta name="twitter:title" content={title} />
+    <meta name="twitter:description" content={description} />
+    <meta name="twitter:image" content={new URL(image, Astro.site)} />
+
+    <!-- Sitemap -->
+    <link rel="sitemap" href="/sitemap-index.xml" />
+  </head>
+  <body>
+    <slot />
+  </body>
+</html>
+```

+ 754 - 0
skills/astro-ops/references/islands-rendering.md

@@ -0,0 +1,754 @@
+# Islands Architecture and Rendering Reference
+
+Deep dive into Astro's islands architecture, partial hydration, client directives, framework integration, and server islands.
+
+## How Islands Architecture Works
+
+Astro renders all components to static HTML on the server by default. Interactive components ("islands") are selectively hydrated on the client, shipping JavaScript only for the parts of the page that need interactivity.
+
+```
+Traditional SPA:
+┌──────────────────────────────────────┐
+│           Full JavaScript App         │  ← All JS shipped
+│  ┌────┐ ┌────┐ ┌────┐ ┌────┐       │
+│  │Nav │ │Hero│ │Card│ │Form│       │
+│  └────┘ └────┘ └────┘ └────┘       │
+└──────────────────────────────────────┘
+
+Astro Islands:
+┌──────────────────────────────────────┐
+│           Static HTML (zero JS)       │
+│  ┌────┐                              │
+│  │Nav │ ← Island (client:load)       │  ← Only island JS shipped
+│  └────┘                              │
+│  ┌────────────┐                      │
+│  │  Hero Text  │ ← Static HTML       │  ← No JS
+│  └────────────┘                      │
+│  ┌────┐ ┌────┐ ┌────┐              │
+│  │Card│ │Card│ │Card│ ← Static      │  ← No JS
+│  └────┘ └────┘ └────┘              │
+│  ┌──────┐                            │
+│  │ Form │ ← Island (client:visible) │  ← JS loaded on scroll
+│  └──────┘                            │
+└──────────────────────────────────────┘
+```
+
+### The Hydration Process
+
+1. **Server**: Astro renders ALL components (including React, Vue, Svelte) to HTML
+2. **Client**: Browser receives pure HTML - instant display, zero JS
+3. **Hydration**: Based on client directive, island JS is loaded and components become interactive
+4. **Result**: Page is visible immediately; interactivity loads progressively
+
+```astro
+---
+// This component renders to HTML on the server
+// Then hydrates with React on the client
+import SearchBar from '../components/SearchBar.tsx';
+---
+
+<!-- Static HTML (no JS) -->
+<header>
+  <h1>My Site</h1>
+  <!-- React island - hydrates when visible -->
+  <SearchBar client:visible placeholder="Search docs..." />
+</header>
+```
+
+## Client Directives Deep Dive
+
+### client:load
+
+Hydrates immediately when the page loads. Highest priority.
+
+```astro
+---
+import AuthButton from '../components/AuthButton.tsx';
+import NavMenu from '../components/NavMenu.tsx';
+---
+
+<!-- Use for: above-fold interactive elements that users interact with immediately -->
+<AuthButton client:load />
+<NavMenu client:load />
+```
+
+**When to use:**
+- Navigation menus that must be interactive on page load
+- Authentication state indicators
+- Critical CTAs above the fold
+- Elements that must respond to first user interaction
+
+**When NOT to use:**
+- Content below the fold (use `client:visible`)
+- Non-critical widgets (use `client:idle`)
+- Large components that aren't immediately needed
+
+### client:idle
+
+Hydrates after the page has finished loading and `requestIdleCallback` fires.
+
+```astro
+---
+import CommentSection from '../components/CommentSection.tsx';
+import NewsletterSignup from '../components/NewsletterSignup.vue';
+import ShareButtons from '../components/ShareButtons.tsx';
+---
+
+<!-- Use for: important but not immediately critical interactivity -->
+<CommentSection client:idle postId={post.id} />
+<NewsletterSignup client:idle />
+<ShareButtons client:idle url={Astro.url} />
+```
+
+**When to use:**
+- Comment sections
+- Newsletter signup forms
+- Social share buttons
+- Chat widgets
+- Analytics dashboards below hero
+
+**Behavior:**
+- Waits for `requestIdleCallback` (or `setTimeout` fallback after 200ms)
+- Doesn't block initial page render or first paint
+- Loads before user scrolls (unlike `client:visible`)
+
+### client:visible
+
+Hydrates when the element enters the viewport (IntersectionObserver).
+
+```astro
+---
+import ImageCarousel from '../components/ImageCarousel.svelte';
+import InteractiveChart from '../components/InteractiveChart.tsx';
+import Testimonials from '../components/Testimonials.vue';
+---
+
+<!-- Use for: below-fold content that's only needed when scrolled to -->
+<ImageCarousel client:visible images={gallery} />
+<InteractiveChart client:visible data={chartData} />
+<Testimonials client:visible />
+
+<!-- With rootMargin - preload 200px before visible -->
+<InteractiveChart client:visible={{rootMargin: "200px"}} data={chartData} />
+```
+
+**When to use:**
+- Image carousels/galleries far down the page
+- Interactive charts and data visualizations
+- Testimonial sliders
+- Footer widgets
+- Any interactive content below the fold
+
+**Behavior:**
+- Uses IntersectionObserver to detect visibility
+- Zero JS loaded until element is about to enter viewport
+- Supports `rootMargin` option for preloading
+
+### client:media
+
+Hydrates only when a CSS media query matches.
+
+```astro
+---
+import MobileMenu from '../components/MobileMenu.tsx';
+import DesktopSidebar from '../components/DesktopSidebar.tsx';
+import DarkModeToggle from '../components/DarkModeToggle.tsx';
+---
+
+<!-- Only hydrate on mobile -->
+<MobileMenu client:media="(max-width: 768px)" />
+
+<!-- Only hydrate on desktop -->
+<DesktopSidebar client:media="(min-width: 1024px)" />
+
+<!-- Hydrate based on user preference -->
+<DarkModeToggle client:media="(prefers-color-scheme: dark)" />
+```
+
+**When to use:**
+- Mobile-only navigation (hamburger menus)
+- Desktop-only sidebars with interactivity
+- Responsive components that differ dramatically by viewport
+- Reduced motion alternatives
+
+**Behavior:**
+- Checks media query on load; hydrates if matched
+- Also watches for changes (e.g., viewport resize triggers hydration)
+- If media query never matches, JS is never loaded
+
+### client:only
+
+Skips server rendering entirely. Component renders ONLY on the client.
+
+```astro
+---
+import ThreeScene from '../components/ThreeScene.tsx';
+import MapComponent from '../components/Map.tsx';
+import CanvasEditor from '../components/CanvasEditor.svelte';
+---
+
+<!-- MUST specify the framework -->
+<ThreeScene client:only="react" />
+<MapComponent client:only="react" />
+<CanvasEditor client:only="svelte" />
+
+<!-- Valid framework values: -->
+<!-- client:only="react" -->
+<!-- client:only="preact" -->
+<!-- client:only="vue" -->
+<!-- client:only="svelte" -->
+<!-- client:only="solid-js" -->
+<!-- client:only="lit" -->
+```
+
+**When to use:**
+- WebGL / Three.js / Canvas components
+- Map libraries (Leaflet, Mapbox) that access `window`
+- Browser-only APIs (Web Audio, WebRTC, etc.)
+- Components that crash during SSR
+
+**Behavior:**
+- No HTML rendered on server (shows nothing until JS loads)
+- No hydration mismatch possible (no server HTML to diff)
+- Framework string is required so Astro knows which renderer to use
+
+### No Directive (Static)
+
+Component renders to HTML only. Zero client-side JavaScript.
+
+```astro
+---
+import Card from '../components/Card.astro';
+import Footer from '../components/Footer.astro';
+import BlogPostPreview from '../components/BlogPostPreview.astro';
+---
+
+<!-- Pure HTML, no JS ever -->
+<Card title="Hello" description="World" />
+<Footer />
+<BlogPostPreview post={post} />
+```
+
+**When to use:**
+- Content display (cards, headers, footers)
+- Anything that doesn't need user interaction
+- Layout components
+- Most of your page (aim for 80%+ static)
+
+## Framework Integration
+
+### Multi-framework Setup
+
+```typescript
+// astro.config.mjs
+import { defineConfig } from 'astro/config';
+import react from '@astrojs/react';
+import vue from '@astrojs/vue';
+import svelte from '@astrojs/svelte';
+import solid from '@astrojs/solid-js';
+import preact from '@astrojs/preact';
+
+export default defineConfig({
+  integrations: [
+    react({
+      include: ['**/react/**'],    // Only process files in react/ dirs
+    }),
+    preact({
+      include: ['**/preact/**'],   // Disambiguate from React
+    }),
+    vue(),
+    svelte(),
+    solid({
+      include: ['**/solid/**'],
+    }),
+  ],
+});
+```
+
+### File Organization for Multi-framework
+
+```
+src/components/
+├── react/              # React components (.tsx/.jsx)
+│   ├── Counter.tsx
+│   └── SearchBar.tsx
+├── vue/                # Vue components (.vue)
+│   ├── TodoList.vue
+│   └── Modal.vue
+├── svelte/             # Svelte components (.svelte)
+│   ├── Carousel.svelte
+│   └── Toggle.svelte
+├── solid/              # Solid components (.tsx)
+│   └── DataGrid.tsx
+├── Header.astro        # Astro (static)
+└── Footer.astro
+```
+
+### Using Multiple Frameworks in One Page
+
+```astro
+---
+import ReactNav from '../components/react/NavBar.tsx';
+import VueForm from '../components/vue/ContactForm.vue';
+import SvelteCarousel from '../components/svelte/Carousel.svelte';
+import Footer from '../components/Footer.astro';
+---
+
+<ReactNav client:load user={user} />
+
+<main>
+  <h1>Multi-framework Page</h1>
+
+  <SvelteCarousel client:visible items={images} />
+
+  <VueForm client:idle endpoint="/api/contact" />
+</main>
+
+<Footer />
+```
+
+## Sharing State Between Islands
+
+Islands are isolated by default. Here are patterns for sharing state.
+
+### Nanostores (Recommended)
+
+```bash
+npm install nanostores @nanostores/react @nanostores/vue @nanostores/svelte
+```
+
+```typescript
+// src/stores/cart.ts
+import { atom, map, computed } from 'nanostores';
+
+// Simple atom
+export const isMenuOpen = atom(false);
+
+// Map (object store)
+export interface CartItem {
+  id: string;
+  name: string;
+  price: number;
+  quantity: number;
+}
+
+export const cartItems = map<Record<string, CartItem>>({});
+
+// Computed (derived state)
+export const cartTotal = computed(cartItems, (items) => {
+  return Object.values(items).reduce(
+    (sum, item) => sum + item.price * item.quantity,
+    0
+  );
+});
+
+// Actions
+export function addToCart(item: CartItem) {
+  const existing = cartItems.get()[item.id];
+  if (existing) {
+    cartItems.setKey(item.id, {
+      ...existing,
+      quantity: existing.quantity + 1,
+    });
+  } else {
+    cartItems.setKey(item.id, { ...item, quantity: 1 });
+  }
+}
+
+export function removeFromCart(id: string) {
+  const items = { ...cartItems.get() };
+  delete items[id];
+  cartItems.set(items);
+}
+```
+
+```tsx
+// React component using the store
+import { useStore } from '@nanostores/react';
+import { cartItems, cartTotal, addToCart } from '../stores/cart';
+
+export function CartButton() {
+  const $items = useStore(cartItems);
+  const $total = useStore(cartTotal);
+  const count = Object.keys($items).length;
+
+  return (
+    <button>
+      Cart ({count}) - ${$total.toFixed(2)}
+    </button>
+  );
+}
+```
+
+```svelte
+<!-- Svelte component using same store -->
+<script>
+  import { cartItems, cartTotal } from '../stores/cart';
+</script>
+
+<div>
+  {#each Object.values($cartItems) as item}
+    <p>{item.name}: ${item.price} x {item.quantity}</p>
+  {/each}
+  <p>Total: ${$cartTotal.toFixed(2)}</p>
+</div>
+```
+
+### Custom Events
+
+```astro
+---
+// For simple one-way communication between islands
+---
+
+<script>
+  // Dispatch from any island
+  document.dispatchEvent(new CustomEvent('cart:add', {
+    detail: { id: '123', name: 'Widget', price: 9.99 }
+  }));
+
+  // Listen in any island
+  document.addEventListener('cart:add', (e: CustomEvent) => {
+    console.log('Added to cart:', e.detail);
+  });
+</script>
+```
+
+### URL State
+
+```typescript
+// Use URL search params for shareable state
+function updateFilter(key: string, value: string) {
+  const url = new URL(window.location.href);
+  url.searchParams.set(key, value);
+  window.history.pushState({}, '', url);
+
+  // Notify other islands
+  document.dispatchEvent(new CustomEvent('url:change', {
+    detail: Object.fromEntries(url.searchParams),
+  }));
+}
+```
+
+## Performance Budgets
+
+### Measuring Island Size
+
+```typescript
+// astro.config.mjs - analyze bundle
+import { defineConfig } from 'astro/config';
+import { visualizer } from 'rollup-plugin-visualizer';
+
+export default defineConfig({
+  vite: {
+    plugins: [
+      visualizer({
+        filename: './dist/stats.html',
+        gzipSize: true,
+        brotliSize: true,
+      }),
+    ],
+  },
+});
+```
+
+### Bundle Size Guidelines
+
+| Component Type | Target Size (gzipped) | Strategy |
+|---------------|----------------------|----------|
+| Critical island (client:load) | < 20 KB | Minimize dependencies |
+| Deferred island (client:idle) | < 50 KB | Acceptable, loads after paint |
+| Lazy island (client:visible) | < 100 KB | OK for rich interactive content |
+| Full SPA island (client:only) | < 200 KB | Consider code splitting |
+
+### Optimization Strategies
+
+```typescript
+// 1. Prefer Preact over React for smaller islands
+import preact from '@astrojs/preact';
+
+// 2. Dynamic imports for heavy dependencies
+const Chart = lazy(() => import('./HeavyChart'));
+
+// 3. Tree-shakeable imports
+import { format } from 'date-fns/format';        // Good: specific import
+// import { format } from 'date-fns';             // Bad: imports everything
+
+// 4. Use Astro's built-in Image optimization
+import { Image } from 'astro:assets';
+```
+
+## Server Islands (Astro 5)
+
+Server islands allow you to defer rendering of specific components to after the initial page response, enabling personalized content within cached pages.
+
+### How Server Islands Work
+
+```
+Request Flow:
+1. Edge/CDN serves cached static HTML instantly
+2. Page displays with fallback content for server islands
+3. Server islands fetch their content via separate requests
+4. Dynamic content streams in and replaces fallbacks
+
+┌─────────────────────────────────┐
+│  Cached Static Page (CDN)       │
+│                                 │
+│  ┌──────────────────────┐      │
+│  │ Static Header         │      │  ← Cached
+│  └──────────────────────┘      │
+│  ┌──────────────────────┐      │
+│  │ server:defer          │      │  ← Fetched separately
+│  │ (user-specific data)  │      │     after page load
+│  └──────────────────────┘      │
+│  ┌──────────────────────┐      │
+│  │ Static Content        │      │  ← Cached
+│  └──────────────────────┘      │
+└─────────────────────────────────┘
+```
+
+### Basic Usage
+
+```astro
+---
+// src/components/UserGreeting.astro
+const user = await getUser(Astro.cookies.get('session'));
+---
+
+<div>
+  <p>Welcome back, {user.name}!</p>
+  <p>You have {user.notifications} new notifications.</p>
+</div>
+```
+
+```astro
+---
+// src/pages/index.astro
+import UserGreeting from '../components/UserGreeting.astro';
+import ProductRecommendations from '../components/ProductRecommendations.astro';
+---
+
+<html>
+  <body>
+    <h1>Welcome to our Store</h1>
+
+    <!-- This component renders on the server AFTER the page is sent -->
+    <UserGreeting server:defer>
+      <!-- Fallback shown while server island loads -->
+      <p slot="fallback">Loading your profile...</p>
+    </UserGreeting>
+
+    <!-- Another server island -->
+    <ProductRecommendations server:defer>
+      <div slot="fallback" class="skeleton-grid">
+        <!-- Skeleton placeholder -->
+      </div>
+    </ProductRecommendations>
+
+    <!-- This is static, served from cache -->
+    <footer>Static footer content</footer>
+  </body>
+</html>
+```
+
+### Server Islands with Props
+
+```astro
+---
+// Server islands can receive serializable props
+import PricingTable from '../components/PricingTable.astro';
+---
+
+<!-- Props are encrypted and sent with the deferred request -->
+<PricingTable
+  server:defer
+  productId="abc123"
+  region={Astro.locals.region}
+>
+  <p slot="fallback">Loading pricing...</p>
+</PricingTable>
+```
+
+### Caching Strategy with Server Islands
+
+```typescript
+// astro.config.mjs
+export default defineConfig({
+  output: 'server',
+  adapter: cloudflare(),
+});
+```
+
+```astro
+---
+// src/pages/product/[id].astro
+// The page itself can be cached aggressively
+Astro.response.headers.set('Cache-Control', 'public, max-age=3600');
+
+import ProductDetails from '../components/ProductDetails.astro';
+import UserReviews from '../components/UserReviews.astro';
+import AddToCart from '../components/AddToCart.astro';
+---
+
+<!-- Static product info (cached) -->
+<ProductDetails productId={Astro.params.id} />
+
+<!-- Dynamic, personalized (server island) -->
+<AddToCart server:defer productId={Astro.params.id}>
+  <button slot="fallback" disabled>Loading...</button>
+</AddToCart>
+
+<!-- Dynamic, frequently updated (server island) -->
+<UserReviews server:defer productId={Astro.params.id}>
+  <p slot="fallback">Loading reviews...</p>
+</UserReviews>
+```
+
+## Slot Patterns
+
+### Passing Astro Content into Framework Islands
+
+```astro
+---
+import ReactAccordion from '../components/react/Accordion.tsx';
+---
+
+<!-- Astro content becomes children in React -->
+<ReactAccordion client:visible title="FAQ">
+  <p>This HTML is passed as children to the React component.</p>
+  <ul>
+    <li>Static content rendered by Astro</li>
+    <li>Hydrated by React when visible</li>
+  </ul>
+</ReactAccordion>
+```
+
+```tsx
+// React component receiving Astro slot content
+interface AccordionProps {
+  title: string;
+  children: React.ReactNode;  // Astro slot content arrives as children
+}
+
+export function Accordion({ title, children }: AccordionProps) {
+  const [isOpen, setIsOpen] = useState(false);
+
+  return (
+    <div>
+      <button onClick={() => setIsOpen(!isOpen)}>{title}</button>
+      {isOpen && <div>{children}</div>}
+    </div>
+  );
+}
+```
+
+### Named Slots with Framework Components
+
+```astro
+---
+import ReactCard from '../components/react/Card.tsx';
+---
+
+<!-- Named slots map to props in React -->
+<ReactCard client:idle>
+  <h2 slot="header">Card Title</h2>
+  <p>Default slot content (becomes children)</p>
+  <span slot="footer">Card footer</span>
+</ReactCard>
+```
+
+```tsx
+// React component with named slots
+interface CardProps {
+  header?: React.ReactNode;
+  footer?: React.ReactNode;
+  children: React.ReactNode;
+}
+
+export function Card({ header, footer, children }: CardProps) {
+  return (
+    <div className="card">
+      {header && <div className="card-header">{header}</div>}
+      <div className="card-body">{children}</div>
+      {footer && <div className="card-footer">{footer}</div>}
+    </div>
+  );
+}
+```
+
+### Nested Islands
+
+```astro
+---
+import ReactWrapper from '../components/react/Wrapper.tsx';
+import SvelteWidget from '../components/svelte/Widget.svelte';
+---
+
+<!-- Nested islands hydrate independently -->
+<ReactWrapper client:load>
+  <!-- This Svelte component hydrates separately from the React wrapper -->
+  <SvelteWidget client:visible count={5} />
+</ReactWrapper>
+```
+
+**Important:** Nested islands are NOT nested in the JavaScript sense. Each island hydrates independently. The React wrapper doesn't "own" the Svelte widget - they just happen to be visually nested in the HTML.
+
+## Advanced Patterns
+
+### Conditional Hydration
+
+```astro
+---
+import HeavyEditor from '../components/react/Editor.tsx';
+const isEditor = Astro.url.searchParams.has('edit');
+---
+
+<!-- Only include the island if editing -->
+{isEditor ? (
+  <HeavyEditor client:load content={content} />
+) : (
+  <div class="content" set:html={renderedContent} />
+)}
+```
+
+### Island with Loading State
+
+```tsx
+// React island with built-in loading state
+import { useState, useEffect } from 'react';
+
+export function DataWidget({ endpoint }: { endpoint: string }) {
+  const [data, setData] = useState(null);
+  const [loading, setLoading] = useState(true);
+
+  useEffect(() => {
+    fetch(endpoint)
+      .then((r) => r.json())
+      .then((d) => { setData(d); setLoading(false); });
+  }, [endpoint]);
+
+  if (loading) return <div class="skeleton" />;
+  return <div>{/* render data */}</div>;
+}
+```
+
+### Transition-aware Islands
+
+```tsx
+// React island that reinitializes on View Transition navigation
+import { useEffect } from 'react';
+
+export function PageTracker() {
+  useEffect(() => {
+    // This runs on initial hydration AND after View Transition navigations
+    const handler = () => {
+      console.log('Page changed:', window.location.pathname);
+    };
+
+    document.addEventListener('astro:page-load', handler);
+    return () => document.removeEventListener('astro:page-load', handler);
+  }, []);
+
+  return null;
+}
+```

+ 0 - 0
skills/astro-ops/scripts/.gitkeep


+ 325 - 0
skills/auth-ops/SKILL.md

@@ -0,0 +1,325 @@
+---
+name: auth-ops
+description: "Authentication and authorization patterns - JWT, OAuth2, sessions, RBAC, ABAC, passkeys, and MFA. Use for: authentication, authorization, jwt, oauth, oauth2, session, login, rbac, abac, passkey, mfa, totp, api key, token, auth, cookie, csrf, cors credentials, bearer token, refresh token, oidc."
+allowed-tools: "Read Write Bash"
+related-skills: [security-ops, api-design-ops, postgres-ops]
+---
+
+# Auth Operations
+
+Comprehensive authentication and authorization patterns for secure application development across languages and frameworks.
+
+## Authentication Method Decision Tree
+
+Use this tree to select the right authentication strategy for your use case.
+
+```
+What are you building?
+│
+├─ Traditional web application (server-rendered)?
+│  └─ Session-based authentication
+│     ├─ Server stores session data (Redis/DB)
+│     ├─ Session ID in httpOnly cookie
+│     └─ Best for: monoliths, SSR apps, admin panels
+│
+├─ API consumed by multiple clients?
+│  └─ JWT (JSON Web Tokens)
+│     ├─ Stateless, self-contained tokens
+│     ├─ Access token (short-lived) + refresh token (long-lived)
+│     └─ Best for: microservices, mobile apps, SPAs via BFF
+│
+├─ Service-to-service communication?
+│  └─ API keys or Client Credentials (OAuth2)
+│     ├─ API keys: simple, scoped, rotatable
+│     ├─ Client Credentials: OAuth2 standard, token-based
+│     └─ Best for: internal services, third-party integrations
+│
+├─ Third-party login (Google, GitHub, etc.)?
+│  └─ OAuth2 / OpenID Connect
+│     ├─ Authorization Code + PKCE for web/mobile
+│     ├─ Delegate identity to trusted providers
+│     └─ Best for: consumer apps, social login
+│
+└─ Passwordless authentication?
+   └─ Passkeys (WebAuthn) or Magic Links
+      ├─ Passkeys: phishing-resistant, biometric/hardware
+      ├─ Magic links: email-based, time-limited
+      └─ Best for: high-security, modern UX
+```
+
+## JWT Quick Reference
+
+### Structure
+
+```
+Header.Payload.Signature
+
+Header:  { "alg": "RS256", "typ": "JWT" }
+Payload: { "iss": "auth.example.com", "sub": "user_123", ... }
+Signature: RSASHA256(base64(header) + "." + base64(payload), privateKey)
+```
+
+### Common Claims
+
+| Claim | Name | Purpose | Example |
+|-------|------|---------|---------|
+| `iss` | Issuer | Who issued the token | `"auth.example.com"` |
+| `sub` | Subject | Who the token represents | `"user_123"` |
+| `exp` | Expiration | When the token expires | `1700000000` (Unix timestamp) |
+| `iat` | Issued At | When the token was created | `1699999100` |
+| `aud` | Audience | Intended recipient(s) | `"api.example.com"` |
+| `jti` | JWT ID | Unique token identifier | `"a1b2c3d4"` (for revocation) |
+| `nbf` | Not Before | Token not valid before this time | `1699999100` |
+
+### Signing Algorithms
+
+| Algorithm | Type | Key | Use When |
+|-----------|------|-----|----------|
+| **RS256** | Asymmetric (RSA) | Public/private key pair | Distributed systems, multiple verifiers |
+| **ES256** | Asymmetric (ECDSA) | Public/private key pair | Same as RS256, smaller keys/signatures |
+| **HS256** | Symmetric (HMAC) | Shared secret | Single service, simple setups |
+
+**Rule of thumb:** Use asymmetric (RS256/ES256) when the token issuer and verifier are different services. Use HS256 only when a single service both creates and verifies tokens.
+
+### Access + Refresh Token Pattern
+
+```
+┌──────────┐                    ┌──────────┐
+│  Client   │─── login ────────>│  Auth    │
+│           │<── access (15m) ──│  Server  │
+│           │<── refresh (7d) ──│          │
+│           │                   └──────────┘
+│           │─── API call ─────>┌──────────┐
+│           │    (access token) │ Resource │
+│           │<── response ──────│  Server  │
+│           │                   └──────────┘
+│           │─── access expired │          │
+│           │─── refresh ──────>│  Auth    │
+│           │<── new access ────│  Server  │
+│           │<── new refresh ───│  (rotate)│
+└──────────┘                    └──────────┘
+```
+
+- **Access token:** Short-lived (5-15 minutes), used for API calls
+- **Refresh token:** Long-lived (7-30 days), used to get new access tokens
+- **Rotation:** Issue a new refresh token with each use, invalidate the old one
+- **Family detection:** Track refresh token lineage; if a revoked token is reused, invalidate the entire family
+
+## OAuth2 Flow Decision Tree
+
+```
+What type of client?
+│
+├─ Web app with backend (Next.js, Rails, Django)?
+│  └─ Authorization Code + PKCE
+│     ├─ Redirect user to authorization server
+│     ├─ Receive code at callback URL
+│     ├─ Exchange code for tokens server-side
+│     └─ PKCE prevents code interception attacks
+│
+├─ SPA (React, Vue) without backend?
+│  └─ Authorization Code + PKCE (via BFF)
+│     ├─ Use a Backend-for-Frontend to handle tokens
+│     ├─ Never store tokens in browser-accessible storage
+│     └─ BFF proxies API calls with token attached
+│
+├─ Mobile app (iOS, Android)?
+│  └─ Authorization Code + PKCE
+│     ├─ Use custom URI scheme or universal links for redirect
+│     ├─ PKCE is mandatory (public client)
+│     └─ Store tokens in secure enclave/keystore
+│
+├─ Server-to-server (no user)?
+│  └─ Client Credentials
+│     ├─ Authenticate with client_id + client_secret
+│     ├─ No user context, service-level access
+│     └─ Token cached until expiry
+│
+├─ CLI tool or smart TV?
+│  └─ Device Code
+│     ├─ Display code and URL to user
+│     ├─ User authenticates on another device
+│     ├─ CLI/TV polls for completion
+│     └─ Good UX for input-constrained devices
+│
+└─ Microservice acting on behalf of a user?
+   └─ Token Exchange (RFC 8693)
+      ├─ Exchange user's token for a scoped downstream token
+      ├─ Maintains user context across services
+      └─ Use `act` claim for delegation chain
+```
+
+## Authorization Model Decision Tree
+
+```
+How complex are your access control needs?
+│
+├─ Simple: just "can user X do action Y"?
+│  └─ Permission-based (direct)
+│     ├─ user_permissions table
+│     ├─ Simple to implement, hard to scale
+│     └─ Good for: small apps, prototypes
+│
+├─ Users grouped into roles with fixed permissions?
+│  └─ RBAC (Role-Based Access Control)
+│     ├─ Roles: admin, editor, viewer
+│     ├─ Each role has a set of permissions
+│     ├─ Users assigned one or more roles
+│     └─ Good for: most apps, admin panels, team tools
+│
+├─ Decisions depend on attributes (time, location, resource owner)?
+│  └─ ABAC (Attribute-Based Access Control)
+│     ├─ Policies evaluate subject + resource + environment attributes
+│     ├─ "Allow if user.department == resource.department AND time < 17:00"
+│     ├─ Flexible but complex
+│     └─ Good for: enterprise, compliance-heavy, context-dependent access
+│
+└─ Access based on relationships (owner, parent, shared with)?
+   └─ ReBAC (Relationship-Based Access Control)
+      ├─ Google Zanzibar model
+      ├─ Tuples: user:alice#viewer@document:report
+      ├─ Supports inheritance: folder viewer → document viewer
+      ├─ Tools: OpenFGA, SpiceDB, Ory Keto
+      └─ Good for: file sharing, nested resources, social features
+```
+
+## Session Management Quick Reference
+
+### Cookie Security Settings
+
+| Setting | Value | Purpose |
+|---------|-------|---------|
+| `SameSite` | `Strict` | Cookie sent only for same-site requests (best CSRF protection) |
+| `SameSite` | `Lax` | Cookie sent for top-level navigations (good default) |
+| `SameSite` | `None` | Cookie sent for cross-site requests (requires `Secure`) |
+| `Secure` | `true` | Cookie only sent over HTTPS |
+| `HttpOnly` | `true` | Cookie not accessible via JavaScript (prevents XSS theft) |
+| `__Host-` prefix | N/A | Requires Secure, no Domain, Path=/ (strictest) |
+| `__Secure-` prefix | N/A | Requires Secure flag |
+| `Max-Age` | seconds | Cookie lifetime (prefer over `Expires`) |
+| `Path` | `/` | Scope cookie to path (usually `/`) |
+
+### Recommended Cookie Configuration
+
+```
+Set-Cookie: __Host-session=abc123;
+  Secure;
+  HttpOnly;
+  SameSite=Lax;
+  Max-Age=86400;
+  Path=/
+```
+
+### Session Expiry Strategies
+
+| Strategy | Typical Value | Notes |
+|----------|---------------|-------|
+| **Idle timeout** | 15-30 minutes | Reset on each request |
+| **Absolute timeout** | 8-24 hours | Force re-authentication |
+| **Sliding window** | 30 min idle, 8h max | Best balance |
+| **Remember me** | 30 days | Extended session, reduced privileges |
+
+## Password Handling Quick Reference
+
+### Hashing Algorithms
+
+| Algorithm | Verdict | Notes |
+|-----------|---------|-------|
+| **argon2id** | BEST | Memory-hard, resists GPU attacks, recommended by OWASP |
+| **bcrypt** | GOOD | Battle-tested, cost factor 12+, 72-byte input limit |
+| **scrypt** | GOOD | Memory-hard, less common library support |
+| **PBKDF2** | ACCEPTABLE | FIPS compliant, use 600k+ iterations with SHA-256 |
+| **SHA-256/512** | BAD | Too fast, no salt built-in, easily brute-forced |
+| **MD5** | NEVER | Broken, rainbow tables widely available |
+
+### Password Rules (NIST 800-63B)
+
+| Rule | Guidance |
+|------|----------|
+| Minimum length | 8 characters (12+ recommended) |
+| Maximum length | At least 64 characters |
+| Complexity rules | Do NOT require special chars/uppercase/numbers |
+| Breached password check | Check against known breached passwords (HaveIBeenPwned API) |
+| Password hints | Do NOT allow |
+| Forced rotation | Do NOT force periodic changes (only on breach) |
+| Paste into password field | ALLOW (supports password managers) |
+
+### Rate Limiting Login Attempts
+
+| Attempt | Response |
+|---------|----------|
+| 1-5 | Normal login |
+| 6-10 | CAPTCHA required |
+| 11-20 | Progressive delays (2s, 4s, 8s...) |
+| 20+ | Temporary account lockout (15-30 min) |
+
+**Important:** Use consistent response times for both success and failure to prevent timing-based username enumeration.
+
+## MFA Quick Reference
+
+### Methods Ranked by Security
+
+| Method | Security | UX | Notes |
+|--------|----------|----|-------|
+| **WebAuthn/Passkeys** | Highest | Good | Phishing-resistant, hardware-backed |
+| **TOTP (Authenticator)** | High | Medium | App-based (Google/Microsoft Authenticator) |
+| **Push notifications** | High | Good | Requires mobile app |
+| **Email OTP** | Medium | Medium | Depends on email security |
+| **SMS OTP** | Low | Easy | SIM swap vulnerable, use as fallback only |
+
+### TOTP Implementation Checklist
+
+- [ ] Generate 160-bit secret (base32 encoded)
+- [ ] Build otpauth:// URI with issuer and account
+- [ ] Display QR code for authenticator scanning
+- [ ] Require verification of first code before enabling
+- [ ] Accept current window +/- 1 (30-second steps)
+- [ ] Generate 8-10 single-use backup codes
+- [ ] Hash backup codes before storing
+- [ ] Allow recovery via verified identity
+
+### Passkey/WebAuthn Checklist
+
+- [ ] Generate cryptographic challenge on server
+- [ ] Set relying party ID (your domain)
+- [ ] Store credential public key and ID
+- [ ] Verify signature on authentication
+- [ ] Support multiple credentials per user
+- [ ] Handle platform vs cross-platform authenticators
+- [ ] Provide fallback auth method
+
+## Common Gotchas
+
+| Gotcha | Why It's Dangerous | Fix |
+|--------|--------------------|-----|
+| JWT stored in localStorage | XSS can steal tokens, no expiry enforcement by browser | Use httpOnly cookies or BFF pattern |
+| Missing PKCE in OAuth2 | Authorization code interception attacks possible | Always use PKCE, even for confidential clients |
+| Role explosion in RBAC | Hundreds of roles become unmanageable | Move to ABAC or ReBAC for complex scenarios |
+| String comparison for tokens | Timing attacks reveal token value character by character | Use constant-time comparison (`crypto.timingSafeEqual`) |
+| No token revocation strategy | Cannot invalidate compromised JWTs before expiry | Short expiry + refresh tokens, or maintain a blocklist |
+| CORS with `credentials: true` | `Access-Control-Allow-Origin: *` does not work with credentials | Specify exact origin, set `Access-Control-Allow-Credentials: true` |
+| `SameSite=None` without `Secure` | Browser silently rejects the cookie | Always pair `SameSite=None` with `Secure` flag |
+| Refresh token reuse without detection | Stolen refresh tokens grant indefinite access | Rotate refresh tokens, detect reuse (token families) |
+| Using OAuth2 Implicit grant | Tokens exposed in URL fragment, no refresh tokens | Use Authorization Code + PKCE instead (Implicit is deprecated) |
+| Password in URL or logs | URLs are logged by proxies, browsers, and servers | Always send credentials in request body or headers |
+| Missing CSRF protection with cookies | Cookie-based auth is vulnerable to cross-site request forgery | Use SameSite cookies + CSRF tokens for state-changing ops |
+| Long-lived access tokens (hours/days) | Large attack window if token is compromised | Keep access tokens to 5-15 minutes, use refresh tokens |
+| Storing API keys in plaintext | Database breach exposes all keys | Hash stored keys (SHA-256 of key), store prefix for lookup |
+| Not validating JWT `aud` claim | Token meant for Service A accepted by Service B | Always validate `aud` matches your service identifier |
+| Session fixation | Attacker sets session ID before login, then hijacks it | Regenerate session ID after authentication |
+| Hardcoded secrets in code | Secrets leak via source control | Use environment variables or secret managers (Vault, AWS SSM) |
+
+## Reference Files
+
+| File | Contents | Lines |
+|------|----------|-------|
+| `references/jwt-sessions.md` | JWT structure, signing, sessions, cookies, CSRF, storage | ~650 |
+| `references/oauth2-oidc.md` | OAuth2 flows, OIDC, provider integration, social login | ~700 |
+| `references/authorization.md` | RBAC, ABAC, ReBAC, RLS, multi-tenant, audit logging | ~600 |
+| `references/implementation.md` | Password hashing, MFA, rate limiting, API keys, reset flows | ~550 |
+
+## See Also
+
+- **security-ops** - Broader security patterns: OWASP, headers, input validation, encryption
+- **api-design-ops** - API design including authentication endpoints, rate limiting
+- **postgres-ops** - Row-level security (RLS) policies for database authorization

+ 0 - 0
skills/auth-ops/assets/.gitkeep


+ 1018 - 0
skills/auth-ops/references/authorization.md

@@ -0,0 +1,1018 @@
+# Authorization Patterns
+
+Comprehensive reference for authorization models: RBAC, ABAC, ReBAC, row-level security, multi-tenant, and audit logging.
+
+## RBAC (Role-Based Access Control)
+
+The most common authorization model. Users are assigned roles, and roles have permissions.
+
+### Data Model
+
+```sql
+-- Core RBAC tables
+CREATE TABLE roles (
+    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    name        TEXT UNIQUE NOT NULL,      -- 'admin', 'editor', 'viewer'
+    description TEXT,
+    created_at  TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE TABLE permissions (
+    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    resource    TEXT NOT NULL,              -- 'posts', 'users', 'settings'
+    action      TEXT NOT NULL,              -- 'create', 'read', 'update', 'delete'
+    UNIQUE(resource, action)
+);
+
+CREATE TABLE role_permissions (
+    role_id       UUID REFERENCES roles(id) ON DELETE CASCADE,
+    permission_id UUID REFERENCES permissions(id) ON DELETE CASCADE,
+    PRIMARY KEY (role_id, permission_id)
+);
+
+CREATE TABLE user_roles (
+    user_id    UUID REFERENCES users(id) ON DELETE CASCADE,
+    role_id    UUID REFERENCES roles(id) ON DELETE CASCADE,
+    granted_by UUID REFERENCES users(id),
+    granted_at TIMESTAMPTZ DEFAULT now(),
+    PRIMARY KEY (user_id, role_id)
+);
+```
+
+### Role Hierarchy
+
+```
+super_admin
+  └─ admin
+       ├─ editor
+       │    └─ viewer
+       └─ moderator
+            └─ viewer
+```
+
+```sql
+-- Role hierarchy table
+CREATE TABLE role_hierarchy (
+    parent_role_id UUID REFERENCES roles(id),
+    child_role_id  UUID REFERENCES roles(id),
+    PRIMARY KEY (parent_role_id, child_role_id)
+);
+
+-- Query: Get all permissions for a user (including inherited)
+WITH RECURSIVE effective_roles AS (
+    -- Direct roles
+    SELECT role_id FROM user_roles WHERE user_id = $1
+    UNION
+    -- Inherited roles (parent inherits child permissions)
+    SELECT rh.child_role_id
+    FROM role_hierarchy rh
+    JOIN effective_roles er ON er.role_id = rh.parent_role_id
+)
+SELECT DISTINCT p.resource, p.action
+FROM effective_roles er
+JOIN role_permissions rp ON rp.role_id = er.role_id
+JOIN permissions p ON p.id = rp.permission_id;
+```
+
+### Middleware Patterns
+
+#### Node.js / Express
+
+```javascript
+// Permission checking middleware
+function requirePermission(resource, action) {
+  return async (req, res, next) => {
+    const userId = req.auth.sub;
+
+    const hasPermission = await db.query(`
+      WITH RECURSIVE effective_roles AS (
+        SELECT role_id FROM user_roles WHERE user_id = $1
+        UNION
+        SELECT rh.child_role_id
+        FROM role_hierarchy rh
+        JOIN effective_roles er ON er.role_id = rh.parent_role_id
+      )
+      SELECT EXISTS (
+        SELECT 1
+        FROM effective_roles er
+        JOIN role_permissions rp ON rp.role_id = er.role_id
+        JOIN permissions p ON p.id = rp.permission_id
+        WHERE p.resource = $2 AND p.action = $3
+      )
+    `, [userId, resource, action]);
+
+    if (!hasPermission.rows[0].exists) {
+      return res.status(403).json({
+        error: 'Forbidden',
+        required: `${action}:${resource}`,
+      });
+    }
+
+    next();
+  };
+}
+
+// Usage
+app.get('/api/posts', requirePermission('posts', 'read'), listPosts);
+app.post('/api/posts', requirePermission('posts', 'create'), createPost);
+app.delete('/api/posts/:id', requirePermission('posts', 'delete'), deletePost);
+```
+
+#### Python / FastAPI
+
+```python
+from fastapi import Depends, HTTPException, status
+from functools import wraps
+
+def require_permission(resource: str, action: str):
+    async def checker(user: User = Depends(get_current_user)):
+        has_perm = await db.fetch_val("""
+            SELECT EXISTS (
+                SELECT 1 FROM user_roles ur
+                JOIN role_permissions rp ON rp.role_id = ur.role_id
+                JOIN permissions p ON p.id = rp.permission_id
+                WHERE ur.user_id = $1 AND p.resource = $2 AND p.action = $3
+            )
+        """, user.id, resource, action)
+
+        if not has_perm:
+            raise HTTPException(
+                status_code=status.HTTP_403_FORBIDDEN,
+                detail=f"Missing permission: {action}:{resource}",
+            )
+        return user
+    return Depends(checker)
+
+# Usage
+@app.get("/api/posts")
+async def list_posts(user: User = require_permission("posts", "read")):
+    return await get_posts()
+
+@app.post("/api/posts")
+async def create_post(
+    post: PostCreate,
+    user: User = require_permission("posts", "create"),
+):
+    return await insert_post(post, user.id)
+```
+
+#### Go
+
+```go
+// Middleware pattern
+func RequirePermission(resource, action string) func(http.Handler) http.Handler {
+    return func(next http.Handler) http.Handler {
+        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+            userID := r.Context().Value("userID").(string)
+
+            allowed, err := checkPermission(r.Context(), userID, resource, action)
+            if err != nil {
+                http.Error(w, "Internal error", http.StatusInternalServerError)
+                return
+            }
+            if !allowed {
+                http.Error(w, "Forbidden", http.StatusForbidden)
+                return
+            }
+
+            next.ServeHTTP(w, r)
+        })
+    }
+}
+
+// Usage with chi router
+r.Route("/api/posts", func(r chi.Router) {
+    r.With(RequirePermission("posts", "read")).Get("/", listPosts)
+    r.With(RequirePermission("posts", "create")).Post("/", createPost)
+    r.With(RequirePermission("posts", "delete")).Delete("/{id}", deletePost)
+})
+```
+
+### When RBAC Breaks Down
+
+| Signal | Problem | Solution |
+|--------|---------|----------|
+| 50+ roles | Role explosion | Consider ABAC |
+| Roles like "alice-docs-editor" | Per-user roles | Consider ReBAC |
+| Roles vary by context | Context-dependent access | Consider ABAC |
+| "Owner can edit own resources" | Relationship-based | Consider ReBAC |
+
+## ABAC (Attribute-Based Access Control)
+
+Decisions based on attributes of the subject, resource, action, and environment.
+
+### Policy Structure
+
+```
+PERMIT if:
+  subject.role == "doctor"
+  AND resource.type == "medical_record"
+  AND resource.department == subject.department
+  AND environment.time BETWEEN 08:00 AND 18:00
+  AND action == "read"
+```
+
+### Implementation
+
+```javascript
+// Policy engine
+class PolicyEngine {
+  constructor(policies) {
+    this.policies = policies;
+  }
+
+  evaluate(subject, resource, action, environment) {
+    for (const policy of this.policies) {
+      const result = policy.evaluate(subject, resource, action, environment);
+      if (result === 'PERMIT') return true;
+      if (result === 'DENY') return false;
+      // 'NOT_APPLICABLE' continues to next policy
+    }
+    return false; // Default deny
+  }
+}
+
+// Define policies
+const policies = [
+  {
+    name: 'owner-full-access',
+    evaluate: (subject, resource, action, env) => {
+      if (resource.ownerId === subject.id) return 'PERMIT';
+      return 'NOT_APPLICABLE';
+    },
+  },
+  {
+    name: 'department-read',
+    evaluate: (subject, resource, action, env) => {
+      if (
+        action === 'read' &&
+        subject.department === resource.department
+      ) {
+        return 'PERMIT';
+      }
+      return 'NOT_APPLICABLE';
+    },
+  },
+  {
+    name: 'business-hours-only',
+    evaluate: (subject, resource, action, env) => {
+      const hour = env.currentTime.getHours();
+      if (resource.classification === 'restricted' && (hour < 8 || hour > 18)) {
+        return 'DENY';
+      }
+      return 'NOT_APPLICABLE';
+    },
+  },
+];
+
+// Usage
+const engine = new PolicyEngine(policies);
+const allowed = engine.evaluate(
+  { id: 'user_123', role: 'doctor', department: 'cardiology' },
+  { type: 'record', ownerId: 'user_456', department: 'cardiology', classification: 'normal' },
+  'read',
+  { currentTime: new Date() }
+);
+```
+
+### Combining Algorithms
+
+| Algorithm | Behavior |
+|-----------|----------|
+| **Deny-overrides** | Any DENY wins (most restrictive) |
+| **Permit-overrides** | Any PERMIT wins (most permissive) |
+| **First-applicable** | First matching policy decides |
+| **Only-one-applicable** | Error if multiple policies match |
+
+**Recommendation:** Use deny-overrides for security-critical systems, first-applicable for performance.
+
+## ReBAC (Relationship-Based Access Control)
+
+Based on Google's Zanzibar paper. Access is determined by relationships between users and resources.
+
+### Core Concepts
+
+**Relationship tuple:** `user:alice#viewer@document:report`
+- Subject: `user:alice`
+- Relation: `viewer`
+- Object: `document:report`
+
+Reading: "Alice is a viewer of document:report"
+
+### Authorization Model (OpenFGA/SpiceDB)
+
+```yaml
+# OpenFGA model
+model:
+  schema 1.1
+
+type user
+
+type organization
+  relations
+    define member: [user]
+    define admin: [user]
+
+type folder
+  relations
+    define org: [organization]
+    define owner: [user]
+    define editor: [user, organization#member] or owner
+    define viewer: [user, organization#member] or editor
+
+type document
+  relations
+    define parent: [folder]
+    define owner: [user]
+    define editor: [user] or owner or editor from parent
+    define viewer: [user] or editor or viewer from parent
+```
+
+### Relationship Tuples
+
+```
+# Alice owns the Engineering folder
+user:alice#owner@folder:engineering
+
+# Engineering folder belongs to Acme org
+organization:acme#org@folder:engineering
+
+# Bob is a member of Acme
+user:bob#member@organization:acme
+
+# Report document is in Engineering folder
+folder:engineering#parent@document:report
+
+# Now: Can Bob view document:report?
+# Bob is member of Acme → Acme is org of Engineering folder
+# → folder viewers include org members → document viewers include folder viewers
+# → YES, Bob can view document:report
+```
+
+### OpenFGA Integration
+
+```javascript
+// OpenFGA SDK
+import { OpenFgaClient } from '@openfga/sdk';
+
+const fga = new OpenFgaClient({
+  apiUrl: process.env.OPENFGA_API_URL,
+  storeId: process.env.OPENFGA_STORE_ID,
+});
+
+// Write a relationship
+await fga.write({
+  writes: [
+    {
+      user: 'user:alice',
+      relation: 'editor',
+      object: 'document:report',
+    },
+  ],
+});
+
+// Check access
+const { allowed } = await fga.check({
+  user: 'user:bob',
+  relation: 'viewer',
+  object: 'document:report',
+});
+
+if (!allowed) {
+  return res.status(403).json({ error: 'Access denied' });
+}
+
+// List objects a user can access
+const { objects } = await fga.listObjects({
+  user: 'user:alice',
+  relation: 'viewer',
+  type: 'document',
+});
+// objects: ['document:report', 'document:spec', ...]
+
+// List users with access to an object
+const { users } = await fga.listUsers({
+  object: { type: 'document', id: 'report' },
+  relation: 'viewer',
+  user_filters: [{ type: 'user' }],
+});
+```
+
+### SpiceDB Integration
+
+```go
+// SpiceDB with authzed-go
+import (
+    v1 "github.com/authzed/authzed-go/proto/authzed/api/v1"
+    "github.com/authzed/authzed-go/v1"
+)
+
+client, err := authzed.NewClient(
+    "localhost:50051",
+    grpc.WithInsecure(),
+    grpcutil.WithInsecureBearerToken("my-token"),
+)
+
+// Write relationship
+_, err = client.WriteRelationships(ctx, &v1.WriteRelationshipsRequest{
+    Updates: []*v1.RelationshipUpdate{
+        {
+            Operation: v1.RelationshipUpdate_OPERATION_CREATE,
+            Relationship: &v1.Relationship{
+                Resource: &v1.ObjectReference{
+                    ObjectType: "document",
+                    ObjectId:   "report",
+                },
+                Relation: "viewer",
+                Subject: &v1.SubjectReference{
+                    Object: &v1.ObjectReference{
+                        ObjectType: "user",
+                        ObjectId:   "alice",
+                    },
+                },
+            },
+        },
+    },
+})
+
+// Check permission
+resp, err := client.CheckPermission(ctx, &v1.CheckPermissionRequest{
+    Resource: &v1.ObjectReference{
+        ObjectType: "document",
+        ObjectId:   "report",
+    },
+    Permission: "view",
+    Subject: &v1.SubjectReference{
+        Object: &v1.ObjectReference{
+            ObjectType: "user",
+            ObjectId:   "bob",
+        },
+    },
+})
+
+if resp.Permissionship == v1.CheckPermissionResponse_PERMISSIONSHIP_HAS_PERMISSION {
+    // Access granted
+}
+```
+
+### When to Use ReBAC
+
+| Scenario | RBAC | ReBAC |
+|----------|------|-------|
+| "Admins can manage users" | Good fit | Overkill |
+| "Owner can edit their documents" | Awkward | Good fit |
+| "Folder viewers can view contained documents" | Cannot model | Good fit |
+| "Shared-with users can view" | Cannot model | Good fit |
+| "Org members can access org resources" | Possible but fragile | Good fit |
+
+## Row-Level Security (RLS)
+
+Database-level authorization that filters query results based on the current user.
+
+### PostgreSQL RLS
+
+```sql
+-- Enable RLS on table
+ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
+
+-- Force RLS for table owner too (optional, for safety)
+ALTER TABLE documents FORCE ROW LEVEL SECURITY;
+
+-- Policy: Users can only see their own documents
+CREATE POLICY "users_own_documents" ON documents
+    FOR ALL
+    USING (owner_id = current_setting('app.current_user_id')::uuid);
+
+-- Policy: Users can see documents shared with them
+CREATE POLICY "shared_documents" ON documents
+    FOR SELECT
+    USING (
+        id IN (
+            SELECT document_id FROM document_shares
+            WHERE user_id = current_setting('app.current_user_id')::uuid
+        )
+    );
+
+-- Policy: Admins can see all documents
+CREATE POLICY "admin_full_access" ON documents
+    FOR ALL
+    USING (
+        EXISTS (
+            SELECT 1 FROM user_roles
+            WHERE user_id = current_setting('app.current_user_id')::uuid
+            AND role = 'admin'
+        )
+    );
+
+-- Set the current user context before queries
+SET app.current_user_id = 'user_abc123';
+SELECT * FROM documents; -- Only returns allowed rows
+```
+
+### Supabase RLS
+
+```sql
+-- Supabase provides auth.uid() and auth.jwt() functions
+
+-- Users can read their own profile
+CREATE POLICY "read_own_profile" ON profiles
+    FOR SELECT USING (auth.uid() = id);
+
+-- Users can update their own profile
+CREATE POLICY "update_own_profile" ON profiles
+    FOR UPDATE USING (auth.uid() = id);
+
+-- Users can read posts in their organization
+CREATE POLICY "org_posts" ON posts
+    FOR SELECT USING (
+        org_id IN (
+            SELECT org_id FROM org_members
+            WHERE user_id = auth.uid()
+        )
+    );
+
+-- Service role bypasses RLS (for admin operations)
+-- Use supabase.createClient(url, SERVICE_ROLE_KEY) for admin access
+```
+
+### Application-Level Row Filtering
+
+When you can't use RLS (e.g., non-PostgreSQL databases):
+
+```javascript
+// Query builder pattern
+class AuthorizedQuery {
+  constructor(user) {
+    this.user = user;
+    this.filters = [];
+  }
+
+  forResource(table) {
+    if (this.user.role === 'admin') {
+      // No filter for admins
+    } else if (this.user.role === 'manager') {
+      this.filters.push(`${table}.org_id = ?`, this.user.orgId);
+    } else {
+      this.filters.push(`${table}.owner_id = ?`, this.user.id);
+    }
+    return this;
+  }
+
+  apply(queryBuilder) {
+    for (const filter of this.filters) {
+      queryBuilder.where(filter);
+    }
+    return queryBuilder;
+  }
+}
+
+// Usage
+const query = new AuthorizedQuery(currentUser)
+  .forResource('documents')
+  .apply(db('documents').select('*'));
+```
+
+## Multi-Tenant Authorization
+
+### Tenant Isolation Strategies
+
+| Strategy | Isolation | Complexity | Use When |
+|----------|-----------|------------|----------|
+| **Shared database, shared schema** | Row-level (tenant_id column) | Low | SaaS with many small tenants |
+| **Shared database, separate schemas** | Schema-level | Medium | Moderate data isolation needs |
+| **Separate databases** | Complete | High | Strict compliance, large tenants |
+
+### Shared Schema with tenant_id
+
+```sql
+-- Every table has a tenant_id
+CREATE TABLE documents (
+    id        UUID PRIMARY KEY,
+    tenant_id UUID NOT NULL REFERENCES tenants(id),
+    title     TEXT NOT NULL,
+    content   TEXT,
+    owner_id  UUID NOT NULL REFERENCES users(id),
+    CONSTRAINT fk_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id)
+);
+
+-- RLS policy enforces tenant isolation
+CREATE POLICY "tenant_isolation" ON documents
+    FOR ALL
+    USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
+
+-- Index for performance
+CREATE INDEX idx_documents_tenant ON documents(tenant_id);
+```
+
+### Tenant-Scoped Roles
+
+```sql
+-- Roles are scoped to a tenant
+CREATE TABLE tenant_user_roles (
+    tenant_id UUID REFERENCES tenants(id),
+    user_id   UUID REFERENCES users(id),
+    role      TEXT NOT NULL, -- 'owner', 'admin', 'member', 'viewer'
+    PRIMARY KEY (tenant_id, user_id)
+);
+
+-- A user can be admin in one tenant and viewer in another
+INSERT INTO tenant_user_roles VALUES
+    ('tenant_1', 'alice', 'admin'),
+    ('tenant_2', 'alice', 'viewer');
+```
+
+### Middleware: Tenant Context
+
+```javascript
+// Express middleware - set tenant context
+async function tenantContext(req, res, next) {
+  // Determine tenant from subdomain, header, or JWT claim
+  const tenantId =
+    req.headers['x-tenant-id'] ||
+    req.auth?.tenantId ||
+    extractFromSubdomain(req.hostname);
+
+  if (!tenantId) {
+    return res.status(400).json({ error: 'Tenant not specified' });
+  }
+
+  // Verify user belongs to tenant
+  const membership = await db.query(
+    'SELECT role FROM tenant_user_roles WHERE tenant_id = $1 AND user_id = $2',
+    [tenantId, req.auth.sub]
+  );
+
+  if (!membership.rows.length) {
+    return res.status(403).json({ error: 'Not a member of this tenant' });
+  }
+
+  req.tenant = { id: tenantId, role: membership.rows[0].role };
+
+  // Set PostgreSQL session variable for RLS
+  await db.query("SET app.current_tenant_id = $1", [tenantId]);
+
+  next();
+}
+```
+
+### Cross-Tenant Access
+
+```javascript
+// Controlled cross-tenant access (e.g., shared documents)
+async function checkCrossTenantAccess(userId, resourceId, targetTenantId) {
+  // Check if resource has cross-tenant sharing enabled
+  const share = await db.query(`
+    SELECT permission FROM cross_tenant_shares
+    WHERE resource_id = $1
+    AND shared_with_tenant_id = $2
+    AND (expires_at IS NULL OR expires_at > now())
+  `, [resourceId, targetTenantId]);
+
+  if (!share.rows.length) return false;
+  return share.rows[0].permission; // 'read', 'write', etc.
+}
+```
+
+## API Authorization
+
+### Scope-Based (OAuth2 Scopes)
+
+```javascript
+// Middleware that checks OAuth2 scopes
+function requireScopes(...scopes) {
+  return (req, res, next) => {
+    const tokenScopes = req.auth.scope?.split(' ') || [];
+    const missing = scopes.filter((s) => !tokenScopes.includes(s));
+    if (missing.length) {
+      return res.status(403).json({
+        error: 'insufficient_scope',
+        missing,
+      });
+    }
+    next();
+  };
+}
+```
+
+### Claims-Based (JWT Claims)
+
+```javascript
+// Check JWT claims for authorization
+function requireClaim(claim, value) {
+  return (req, res, next) => {
+    if (req.auth[claim] !== value) {
+      return res.status(403).json({ error: `Required claim: ${claim}=${value}` });
+    }
+    next();
+  };
+}
+
+// Usage
+app.get('/admin', requireClaim('role', 'admin'), adminDashboard);
+app.get('/org/:orgId', (req, res, next) => {
+  if (req.auth.org_id !== req.params.orgId) {
+    return res.status(403).json({ error: 'Wrong organization' });
+  }
+  next();
+}, orgDashboard);
+```
+
+### API Key Permissions
+
+```javascript
+// API key with scoped permissions
+async function apiKeyAuth(req, res, next) {
+  const apiKey = req.headers['x-api-key'];
+  if (!apiKey) return res.status(401).json({ error: 'API key required' });
+
+  // Look up by prefix, verify by hash
+  const prefix = apiKey.substring(0, 8);
+  const keyRecord = await db.query(
+    'SELECT * FROM api_keys WHERE prefix = $1 AND revoked = false',
+    [prefix]
+  );
+
+  if (!keyRecord.rows.length) {
+    return res.status(401).json({ error: 'Invalid API key' });
+  }
+
+  const record = keyRecord.rows[0];
+  const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
+
+  if (!crypto.timingSafeEqual(
+    Buffer.from(keyHash),
+    Buffer.from(record.key_hash)
+  )) {
+    return res.status(401).json({ error: 'Invalid API key' });
+  }
+
+  // Check expiry
+  if (record.expires_at && record.expires_at < new Date()) {
+    return res.status(401).json({ error: 'API key expired' });
+  }
+
+  req.apiKey = {
+    id: record.id,
+    permissions: record.permissions, // ['read:data', 'write:data']
+    rateLimitTier: record.rate_limit_tier,
+  };
+
+  next();
+}
+```
+
+## Feature Flags as Authorization
+
+Feature flags can serve as a lightweight authorization mechanism for feature rollouts.
+
+```javascript
+// Simple feature flag implementation
+class FeatureFlags {
+  constructor(config) {
+    this.flags = config;
+  }
+
+  isEnabled(flag, context = {}) {
+    const config = this.flags[flag];
+    if (!config) return false;
+
+    // Global enable/disable
+    if (typeof config === 'boolean') return config;
+
+    // Percentage rollout
+    if (config.percentage !== undefined) {
+      const hash = this.hashUser(context.userId);
+      return hash % 100 < config.percentage;
+    }
+
+    // User allowlist
+    if (config.allowedUsers?.includes(context.userId)) return true;
+
+    // Role-based
+    if (config.allowedRoles?.includes(context.role)) return true;
+
+    // Tenant-based
+    if (config.allowedTenants?.includes(context.tenantId)) return true;
+
+    return config.defaultValue ?? false;
+  }
+
+  hashUser(userId) {
+    return parseInt(
+      crypto.createHash('md5').update(userId).digest('hex').slice(0, 8),
+      16
+    ) % 100;
+  }
+}
+
+// Usage
+const flags = new FeatureFlags({
+  new_editor: { percentage: 25 },
+  beta_api: { allowedRoles: ['admin'], allowedTenants: ['acme'] },
+  dark_mode: true,
+});
+
+if (flags.isEnabled('new_editor', { userId: user.id })) {
+  // Show new editor
+}
+```
+
+## Audit Logging
+
+### What to Log
+
+| Category | Events |
+|----------|--------|
+| **Authentication** | Login success/failure, logout, password change, MFA enable/disable |
+| **Authorization** | Permission denied, role changes, policy evaluations |
+| **Data access** | Read sensitive data, export data, search queries |
+| **Data modification** | Create, update, delete operations |
+| **Admin actions** | User management, configuration changes, key rotation |
+| **Security** | Suspicious activity, rate limit hits, blocked requests |
+
+### Audit Log Schema
+
+```sql
+CREATE TABLE audit_logs (
+    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    timestamp   TIMESTAMPTZ NOT NULL DEFAULT now(),
+    actor_id    UUID,                          -- Who did it
+    actor_type  TEXT NOT NULL,                 -- 'user', 'service', 'system'
+    action      TEXT NOT NULL,                 -- 'user.login', 'document.delete'
+    resource    TEXT,                          -- 'document:123', 'user:456'
+    outcome     TEXT NOT NULL,                 -- 'success', 'failure', 'denied'
+    metadata    JSONB DEFAULT '{}',            -- Additional context
+    ip_address  INET,
+    user_agent  TEXT,
+    tenant_id   UUID,
+    request_id  UUID                           -- Correlation ID
+);
+
+-- Index for common queries
+CREATE INDEX idx_audit_actor ON audit_logs(actor_id, timestamp DESC);
+CREATE INDEX idx_audit_action ON audit_logs(action, timestamp DESC);
+CREATE INDEX idx_audit_resource ON audit_logs(resource, timestamp DESC);
+CREATE INDEX idx_audit_tenant ON audit_logs(tenant_id, timestamp DESC);
+
+-- Prevent modification (append-only)
+REVOKE UPDATE, DELETE ON audit_logs FROM app_user;
+```
+
+### Audit Logging Implementation
+
+```javascript
+// Audit logger
+class AuditLogger {
+  constructor(db) {
+    this.db = db;
+  }
+
+  async log(event) {
+    await this.db.query(`
+      INSERT INTO audit_logs
+        (actor_id, actor_type, action, resource, outcome, metadata, ip_address, user_agent, tenant_id, request_id)
+      VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
+    `, [
+      event.actorId,
+      event.actorType || 'user',
+      event.action,
+      event.resource,
+      event.outcome || 'success',
+      JSON.stringify(event.metadata || {}),
+      event.ipAddress,
+      event.userAgent,
+      event.tenantId,
+      event.requestId,
+    ]);
+  }
+}
+
+// Express middleware for automatic audit logging
+function auditMiddleware(action, resourceFn) {
+  return (req, res, next) => {
+    const originalJson = res.json.bind(res);
+
+    res.json = (body) => {
+      const resource = resourceFn ? resourceFn(req, body) : undefined;
+      const outcome = res.statusCode < 400 ? 'success' : 'failure';
+
+      // Fire and forget (don't block response)
+      auditLogger.log({
+        actorId: req.auth?.sub,
+        actorType: 'user',
+        action,
+        resource,
+        outcome,
+        metadata: {
+          method: req.method,
+          path: req.path,
+          statusCode: res.statusCode,
+        },
+        ipAddress: req.ip,
+        userAgent: req.headers['user-agent'],
+        tenantId: req.tenant?.id,
+        requestId: req.headers['x-request-id'],
+      }).catch(console.error);
+
+      return originalJson(body);
+    };
+
+    next();
+  };
+}
+
+// Usage
+app.delete('/api/documents/:id',
+  auditMiddleware('document.delete', (req) => `document:${req.params.id}`),
+  requirePermission('documents', 'delete'),
+  deleteDocument
+);
+```
+
+### Compliance Considerations
+
+| Requirement | Implementation |
+|-------------|----------------|
+| **Immutability** | Append-only table, no UPDATE/DELETE permissions |
+| **Retention** | Partition by month, archive to cold storage after N months |
+| **Integrity** | Hash chain (each entry includes hash of previous) |
+| **Access** | Separate read permissions for audit logs |
+| **Availability** | Async writes with retry queue, separate storage |
+| **Search** | Index on actor, action, resource, timestamp |
+
+## Testing Authorization
+
+### Unit Testing Policies
+
+```javascript
+// Test RBAC permissions
+describe('Authorization', () => {
+  it('admin can delete posts', async () => {
+    const allowed = await checkPermission('admin', 'posts', 'delete');
+    expect(allowed).toBe(true);
+  });
+
+  it('viewer cannot delete posts', async () => {
+    const allowed = await checkPermission('viewer', 'posts', 'delete');
+    expect(allowed).toBe(false);
+  });
+
+  it('editor can update posts', async () => {
+    const allowed = await checkPermission('editor', 'posts', 'update');
+    expect(allowed).toBe(true);
+  });
+});
+```
+
+### Permission Matrix Testing
+
+```javascript
+// Test every role × resource × action combination
+const matrix = {
+  admin:   { posts: ['create', 'read', 'update', 'delete'], users: ['create', 'read', 'update', 'delete'] },
+  editor:  { posts: ['create', 'read', 'update'],           users: ['read'] },
+  viewer:  { posts: ['read'],                                users: ['read'] },
+};
+
+for (const [role, permissions] of Object.entries(matrix)) {
+  for (const [resource, actions] of Object.entries(permissions)) {
+    for (const action of ['create', 'read', 'update', 'delete']) {
+      const expected = actions.includes(action);
+      it(`${role} ${expected ? 'can' : 'cannot'} ${action} ${resource}`, async () => {
+        const result = await checkPermission(role, resource, action);
+        expect(result).toBe(expected);
+      });
+    }
+  }
+}
+```
+
+### Integration Testing
+
+```javascript
+// Test authorization at the HTTP level
+describe('POST /api/posts', () => {
+  it('returns 403 for viewer', async () => {
+    const res = await request(app)
+      .post('/api/posts')
+      .set('Authorization', `Bearer ${viewerToken}`)
+      .send({ title: 'Test' });
+    expect(res.status).toBe(403);
+  });
+
+  it('returns 201 for editor', async () => {
+    const res = await request(app)
+      .post('/api/posts')
+      .set('Authorization', `Bearer ${editorToken}`)
+      .send({ title: 'Test' });
+    expect(res.status).toBe(201);
+  });
+
+  it('prevents cross-tenant access', async () => {
+    const res = await request(app)
+      .get('/api/posts/123') // belongs to tenant_1
+      .set('Authorization', `Bearer ${tenant2UserToken}`);
+    expect(res.status).toBe(404); // 404 not 403 to avoid info leakage
+  });
+});
+```

+ 1230 - 0
skills/auth-ops/references/implementation.md

@@ -0,0 +1,1230 @@
+# Implementation Patterns
+
+Practical implementation reference for password hashing, MFA, rate limiting, API keys, and account security flows.
+
+## Password Hashing
+
+### Algorithm Comparison
+
+| Algorithm | Type | Resistance | Recommendation |
+|-----------|------|------------|----------------|
+| **argon2id** | Memory-hard | GPU, ASIC, side-channel | Best choice for new systems |
+| **bcrypt** | CPU-hard | GPU (moderate) | Battle-tested, widely supported |
+| **scrypt** | Memory-hard | GPU, ASIC | Good but less library support |
+| **PBKDF2** | CPU-hard | GPU (weak) | FIPS compliant, last resort |
+
+### argon2id (Recommended)
+
+OWASP recommended parameters:
+- Memory: 19 MiB (19456 KiB)
+- Iterations: 2
+- Parallelism: 1
+- Salt: 16 bytes (random)
+- Hash length: 32 bytes
+
+```javascript
+// Node.js (argon2 package)
+import argon2 from 'argon2';
+
+// Hash a password
+async function hashPassword(password) {
+  return argon2.hash(password, {
+    type: argon2.argon2id,
+    memoryCost: 19456,    // 19 MiB
+    timeCost: 2,           // 2 iterations
+    parallelism: 1,
+    saltLength: 16,
+    hashLength: 32,
+  });
+  // Returns: $argon2id$v=19$m=19456,t=2,p=1$salt$hash
+}
+
+// Verify a password
+async function verifyPassword(hash, password) {
+  return argon2.verify(hash, password);
+}
+
+// Check if rehash needed (parameters changed)
+function needsRehash(hash) {
+  return argon2.needsRehash(hash, {
+    type: argon2.argon2id,
+    memoryCost: 19456,
+    timeCost: 2,
+    parallelism: 1,
+  });
+}
+```
+
+```python
+# Python (argon2-cffi)
+from argon2 import PasswordHasher
+from argon2.exceptions import VerifyMismatchError
+
+ph = PasswordHasher(
+    memory_cost=19456,  # 19 MiB
+    time_cost=2,
+    parallelism=1,
+    hash_len=32,
+    salt_len=16,
+)
+
+# Hash
+hashed = ph.hash("user_password")
+# $argon2id$v=19$m=19456,t=2,p=1$salt$hash
+
+# Verify
+try:
+    ph.verify(hashed, "user_password")
+    # Check if rehash needed (parameters updated)
+    if ph.check_needs_rehash(hashed):
+        new_hash = ph.hash("user_password")
+        # Update stored hash
+except VerifyMismatchError:
+    # Wrong password
+    pass
+```
+
+```go
+// Go (alexedwards/argon2id)
+import "github.com/alexedwards/argon2id"
+
+// Hash
+hash, err := argon2id.CreateHash("user_password", &argon2id.Params{
+    Memory:      19 * 1024, // 19 MiB
+    Iterations:  2,
+    Parallelism: 1,
+    SaltLength:  16,
+    KeyLength:   32,
+})
+
+// Verify
+match, err := argon2id.ComparePasswordAndHash("user_password", hash)
+if match {
+    // Password is correct
+}
+```
+
+### bcrypt
+
+Use cost factor 12 or higher (each increment doubles computation time).
+
+```javascript
+// Node.js (bcrypt)
+import bcrypt from 'bcrypt';
+
+const COST_FACTOR = 12;
+
+// Hash
+const hash = await bcrypt.hash(password, COST_FACTOR);
+
+// Verify
+const match = await bcrypt.compare(password, hash);
+```
+
+```python
+# Python (bcrypt)
+import bcrypt
+
+# Hash
+salt = bcrypt.gensalt(rounds=12)
+hashed = bcrypt.hashpw(password.encode(), salt)
+
+# Verify
+match = bcrypt.checkpw(password.encode(), hashed)
+```
+
+```go
+// Go (golang.org/x/crypto/bcrypt)
+import "golang.org/x/crypto/bcrypt"
+
+// Hash
+hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
+
+// Verify
+err = bcrypt.CompareHashAndPassword(hash, []byte(password))
+if err == nil {
+    // Password is correct
+}
+```
+
+**bcrypt limitation:** Input truncated to 72 bytes. For passwords that might exceed this, pre-hash with SHA-256:
+
+```javascript
+import crypto from 'crypto';
+import bcrypt from 'bcrypt';
+
+function prehashPassword(password) {
+  // SHA-256 produces 32 bytes (base64: 44 chars), well under 72
+  return crypto.createHash('sha256').update(password).digest('base64');
+}
+
+const hash = await bcrypt.hash(prehashPassword(password), 12);
+const match = await bcrypt.compare(prehashPassword(password), hash);
+```
+
+### Password Rehashing on Login
+
+When upgrading from a weaker algorithm (e.g., bcrypt to argon2id), rehash transparently on successful login:
+
+```javascript
+async function login(email, password) {
+  const user = await db.findUserByEmail(email);
+  if (!user) return null;
+
+  // Verify with current algorithm
+  const valid = await verifyPassword(user.passwordHash, password);
+  if (!valid) return null;
+
+  // Check if rehash needed (algorithm or parameter upgrade)
+  if (needsRehash(user.passwordHash)) {
+    const newHash = await hashPassword(password);
+    await db.updatePasswordHash(user.id, newHash);
+  }
+
+  return user;
+}
+```
+
+## Rate Limiting Login Attempts
+
+### Sliding Window Implementation
+
+```javascript
+// Redis-based rate limiter
+import Redis from 'ioredis';
+const redis = new Redis(process.env.REDIS_URL);
+
+class LoginRateLimiter {
+  constructor(options = {}) {
+    this.maxAttempts = options.maxAttempts || 10;
+    this.windowMs = options.windowMs || 15 * 60 * 1000; // 15 minutes
+    this.lockoutMs = options.lockoutMs || 30 * 60 * 1000; // 30 minutes
+  }
+
+  async checkLimit(identifier) {
+    // identifier = email or IP address
+    const key = `login_attempts:${identifier}`;
+    const lockKey = `login_lockout:${identifier}`;
+
+    // Check for lockout
+    const locked = await redis.get(lockKey);
+    if (locked) {
+      const ttl = await redis.ttl(lockKey);
+      return {
+        allowed: false,
+        retryAfter: ttl,
+        reason: 'Account temporarily locked',
+      };
+    }
+
+    // Count recent attempts
+    const now = Date.now();
+    const windowStart = now - this.windowMs;
+
+    // Remove old entries
+    await redis.zremrangebyscore(key, 0, windowStart);
+
+    // Count current attempts
+    const attempts = await redis.zcard(key);
+
+    if (attempts >= this.maxAttempts) {
+      // Lock the account
+      await redis.set(lockKey, '1', 'PX', this.lockoutMs);
+      return {
+        allowed: false,
+        retryAfter: Math.ceil(this.lockoutMs / 1000),
+        reason: 'Too many login attempts',
+      };
+    }
+
+    return {
+      allowed: true,
+      remaining: this.maxAttempts - attempts - 1,
+    };
+  }
+
+  async recordAttempt(identifier) {
+    const key = `login_attempts:${identifier}`;
+    const now = Date.now();
+    await redis.zadd(key, now, `${now}`);
+    await redis.pexpire(key, this.windowMs);
+  }
+
+  async resetAttempts(identifier) {
+    // Call on successful login
+    await redis.del(`login_attempts:${identifier}`);
+    await redis.del(`login_lockout:${identifier}`);
+  }
+}
+
+// Usage in login endpoint
+const limiter = new LoginRateLimiter();
+
+app.post('/auth/login', async (req, res) => {
+  const { email, password } = req.body;
+
+  // Rate limit by both email and IP
+  const emailCheck = await limiter.checkLimit(email);
+  const ipCheck = await limiter.checkLimit(req.ip);
+
+  if (!emailCheck.allowed || !ipCheck.allowed) {
+    return res.status(429).json({
+      error: 'Too many attempts',
+      retryAfter: Math.max(emailCheck.retryAfter || 0, ipCheck.retryAfter || 0),
+    });
+  }
+
+  const user = await authenticate(email, password);
+
+  if (!user) {
+    // Record failed attempt for both identifiers
+    await limiter.recordAttempt(email);
+    await limiter.recordAttempt(req.ip);
+
+    // IMPORTANT: Use consistent timing to prevent enumeration
+    return res.status(401).json({ error: 'Invalid credentials' });
+  }
+
+  // Reset on successful login
+  await limiter.resetAttempts(email);
+  await limiter.resetAttempts(req.ip);
+
+  // Create session/token
+  const token = await createAccessToken(user);
+  res.json({ token });
+});
+```
+
+### Progressive Delays
+
+```javascript
+// Add artificial delay based on attempt count
+async function loginWithDelay(email, password) {
+  const attempts = await getRecentAttempts(email);
+
+  // Progressive delay: 0, 0, 0, 1s, 2s, 4s, 8s, 16s (cap at 30s)
+  if (attempts > 3) {
+    const delay = Math.min(Math.pow(2, attempts - 3) * 1000, 30000);
+    await new Promise((resolve) => setTimeout(resolve, delay));
+  }
+
+  // IMPORTANT: Apply delay for both success and failure
+  // to prevent timing-based enumeration
+  return authenticate(email, password);
+}
+```
+
+## MFA Implementation
+
+### TOTP (Time-Based One-Time Password)
+
+Based on RFC 6238. Uses a shared secret and current time to generate 6-digit codes that change every 30 seconds.
+
+```javascript
+// Node.js (otplib)
+import { authenticator } from 'otplib';
+import QRCode from 'qrcode';
+
+// Step 1: Generate secret for user
+function generateTOTPSecret(userEmail, issuer = 'MyApp') {
+  const secret = authenticator.generateSecret(); // Base32 encoded
+
+  // Build otpauth:// URI for QR code
+  const otpauthUrl = authenticator.keyuri(userEmail, issuer, secret);
+
+  return { secret, otpauthUrl };
+}
+
+// Step 2: Generate QR code
+async function generateQRCode(otpauthUrl) {
+  return QRCode.toDataURL(otpauthUrl);
+  // Returns base64 PNG image for display
+}
+
+// Step 3: Verify first code (enrollment)
+function verifyTOTP(secret, token) {
+  // Accept current window +/- 1 (90 second window)
+  return authenticator.check(token, secret);
+}
+
+// Step 4: Generate backup codes
+function generateBackupCodes(count = 10) {
+  const codes = [];
+  for (let i = 0; i < count; i++) {
+    // 8 character alphanumeric codes
+    codes.push(crypto.randomBytes(4).toString('hex'));
+  }
+  return codes;
+}
+
+// Step 5: Hash backup codes before storing
+async function hashBackupCodes(codes) {
+  return Promise.all(
+    codes.map(async (code) => ({
+      hash: crypto.createHash('sha256').update(code).digest('hex'),
+      used: false,
+    }))
+  );
+}
+```
+
+```python
+# Python (pyotp)
+import pyotp
+import qrcode
+import io
+import secrets
+
+def generate_totp_secret(user_email: str, issuer: str = "MyApp"):
+    secret = pyotp.random_base32()
+    totp = pyotp.TOTP(secret)
+    provisioning_uri = totp.provisioning_uri(
+        name=user_email,
+        issuer_name=issuer,
+    )
+    return secret, provisioning_uri
+
+def verify_totp(secret: str, token: str) -> bool:
+    totp = pyotp.TOTP(secret)
+    # valid_window=1 accepts current +/- 1 time step
+    return totp.verify(token, valid_window=1)
+
+def generate_backup_codes(count: int = 10) -> list[str]:
+    return [secrets.token_hex(4) for _ in range(count)]
+```
+
+```go
+// Go (pquerna/otp)
+import (
+    "github.com/pquerna/otp/totp"
+)
+
+func GenerateTOTPSecret(email, issuer string) (*otp.Key, error) {
+    key, err := totp.Generate(totp.GenerateOpts{
+        Issuer:      issuer,
+        AccountName: email,
+        Period:      30,
+        Digits:      otp.DigitsSix,
+        Algorithm:   otp.AlgorithmSHA1,
+    })
+    return key, err
+    // key.Secret() - base32 secret
+    // key.URL() - otpauth:// URI
+}
+
+func VerifyTOTP(secret, token string) bool {
+    valid, _ := totp.ValidateCustom(token, secret, time.Now(), totp.ValidateOpts{
+        Period:    30,
+        Digits:   otp.DigitsSix,
+        Algorithm: otp.AlgorithmSHA1,
+        Skew:     1, // Accept +/- 1 time step
+    })
+    return valid
+}
+```
+
+### TOTP Enrollment Flow
+
+```javascript
+// POST /auth/mfa/setup - Start TOTP enrollment
+app.post('/auth/mfa/setup', requireAuth, async (req, res) => {
+  const user = await getUser(req.auth.sub);
+
+  if (user.mfaEnabled) {
+    return res.status(400).json({ error: 'MFA already enabled' });
+  }
+
+  const { secret, otpauthUrl } = generateTOTPSecret(user.email);
+  const qrCode = await generateQRCode(otpauthUrl);
+
+  // Store secret temporarily (not yet confirmed)
+  await db.users.update(req.auth.sub, { pendingMfaSecret: secret });
+
+  res.json({
+    qrCode,           // Base64 PNG
+    secret,           // Manual entry fallback
+    otpauthUrl,       // Direct URL for authenticator
+  });
+});
+
+// POST /auth/mfa/verify - Confirm enrollment
+app.post('/auth/mfa/verify', requireAuth, async (req, res) => {
+  const { token } = req.body;
+  const user = await getUser(req.auth.sub);
+
+  if (!user.pendingMfaSecret) {
+    return res.status(400).json({ error: 'No pending MFA setup' });
+  }
+
+  if (!verifyTOTP(user.pendingMfaSecret, token)) {
+    return res.status(400).json({ error: 'Invalid code' });
+  }
+
+  // Generate backup codes
+  const backupCodes = generateBackupCodes(10);
+  const hashedCodes = await hashBackupCodes(backupCodes);
+
+  // Activate MFA
+  await db.users.update(req.auth.sub, {
+    mfaSecret: user.pendingMfaSecret,
+    pendingMfaSecret: null,
+    mfaEnabled: true,
+    backupCodes: hashedCodes,
+  });
+
+  // Show backup codes ONCE - user must save them
+  res.json({
+    success: true,
+    backupCodes, // Plaintext, shown only once
+    message: 'Save these backup codes in a secure location',
+  });
+});
+```
+
+### WebAuthn / Passkeys
+
+Passkeys provide phishing-resistant authentication using public key cryptography backed by hardware (platform authenticator, security key, or synced passkey).
+
+```javascript
+// Server (using @simplewebauthn/server)
+import {
+  generateRegistrationOptions,
+  verifyRegistrationResponse,
+  generateAuthenticationOptions,
+  verifyAuthenticationResponse,
+} from '@simplewebauthn/server';
+
+const rpName = 'My Application';
+const rpID = 'example.com';
+const origin = 'https://example.com';
+
+// --- Registration (creating a passkey) ---
+
+// Step 1: Generate options
+app.post('/auth/passkey/register/options', requireAuth, async (req, res) => {
+  const user = await getUser(req.auth.sub);
+  const existingCredentials = await db.credentials.findByUser(user.id);
+
+  const options = await generateRegistrationOptions({
+    rpName,
+    rpID,
+    userID: user.id,
+    userName: user.email,
+    userDisplayName: user.name,
+    attestationType: 'none',
+    excludeCredentials: existingCredentials.map((c) => ({
+      id: c.credentialId,
+      type: 'public-key',
+    })),
+    authenticatorSelection: {
+      residentKey: 'preferred',
+      userVerification: 'preferred',
+    },
+  });
+
+  // Store challenge for verification
+  await db.challenges.upsert(user.id, options.challenge);
+
+  res.json(options);
+});
+
+// Step 2: Verify registration
+app.post('/auth/passkey/register/verify', requireAuth, async (req, res) => {
+  const user = await getUser(req.auth.sub);
+  const challenge = await db.challenges.get(user.id);
+
+  const verification = await verifyRegistrationResponse({
+    response: req.body,
+    expectedChallenge: challenge,
+    expectedOrigin: origin,
+    expectedRPID: rpID,
+  });
+
+  if (verification.verified && verification.registrationInfo) {
+    const { credentialID, credentialPublicKey, counter } =
+      verification.registrationInfo;
+
+    await db.credentials.create({
+      userId: user.id,
+      credentialId: credentialID,
+      publicKey: credentialPublicKey,
+      counter,
+      name: req.body.name || 'My passkey',
+      createdAt: new Date(),
+    });
+  }
+
+  res.json({ verified: verification.verified });
+});
+
+// --- Authentication (using a passkey) ---
+
+// Step 1: Generate options
+app.post('/auth/passkey/login/options', async (req, res) => {
+  const options = await generateAuthenticationOptions({
+    rpID,
+    userVerification: 'preferred',
+    // For discoverable credentials (passkeys), no need to specify allowCredentials
+  });
+
+  // Store challenge (keyed by session or response)
+  await db.challenges.upsertBySession(req.sessionID, options.challenge);
+
+  res.json(options);
+});
+
+// Step 2: Verify authentication
+app.post('/auth/passkey/login/verify', async (req, res) => {
+  const challenge = await db.challenges.getBySession(req.sessionID);
+  const credential = await db.credentials.findByCredentialId(req.body.id);
+
+  if (!credential) {
+    return res.status(401).json({ error: 'Unknown credential' });
+  }
+
+  const verification = await verifyAuthenticationResponse({
+    response: req.body,
+    expectedChallenge: challenge,
+    expectedOrigin: origin,
+    expectedRPID: rpID,
+    authenticator: {
+      credentialID: credential.credentialId,
+      credentialPublicKey: credential.publicKey,
+      counter: credential.counter,
+    },
+  });
+
+  if (verification.verified) {
+    // Update counter to prevent replay attacks
+    await db.credentials.updateCounter(
+      credential.id,
+      verification.authenticationInfo.newCounter
+    );
+
+    // Create session
+    const user = await getUser(credential.userId);
+    const token = await createAccessToken(user);
+    res.json({ token });
+  } else {
+    res.status(401).json({ error: 'Verification failed' });
+  }
+});
+```
+
+### Backup Code Verification
+
+```javascript
+async function verifyBackupCode(userId, code) {
+  const user = await db.users.findOne(userId);
+  const codeHash = crypto.createHash('sha256').update(code).digest('hex');
+
+  const matchingCode = user.backupCodes.find(
+    (bc) => !bc.used && crypto.timingSafeEqual(
+      Buffer.from(bc.hash),
+      Buffer.from(codeHash)
+    )
+  );
+
+  if (!matchingCode) return false;
+
+  // Mark code as used
+  matchingCode.used = true;
+  matchingCode.usedAt = new Date();
+  await db.users.update(userId, { backupCodes: user.backupCodes });
+
+  // Warn if running low
+  const remaining = user.backupCodes.filter((bc) => !bc.used).length;
+  if (remaining <= 2) {
+    await sendEmail(user.email, 'Low backup codes warning',
+      `You have ${remaining} backup codes remaining. Consider generating new ones.`
+    );
+  }
+
+  return true;
+}
+```
+
+## Secure Password Reset
+
+### Flow
+
+```
+1. User requests reset → generate token → send email
+2. User clicks link → verify token → show reset form
+3. User submits new password → validate token again → update password
+4. Invalidate token → invalidate all sessions → notify user
+```
+
+### Implementation
+
+```javascript
+// Step 1: Request password reset
+app.post('/auth/forgot-password', async (req, res) => {
+  const { email } = req.body;
+
+  // Rate limit: max 3 reset requests per hour per email
+  const rateOk = await checkRateLimit(`reset:${email}`, 3, 3600);
+  if (!rateOk) {
+    // Still return 200 to prevent enumeration
+    return res.json({ message: 'If the email exists, a reset link was sent' });
+  }
+
+  const user = await db.findUserByEmail(email);
+
+  if (user) {
+    // Generate cryptographically random token
+    const token = crypto.randomBytes(32).toString('hex');
+    const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
+
+    await db.passwordResets.create({
+      userId: user.id,
+      tokenHash,
+      expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
+      used: false,
+    });
+
+    // Delete any previous unused reset tokens for this user
+    await db.passwordResets.deleteUnused(user.id, tokenHash);
+
+    await sendEmail(user.email, 'Password Reset', {
+      resetUrl: `https://app.example.com/reset-password?token=${token}`,
+      expiresIn: '1 hour',
+    });
+  }
+
+  // ALWAYS return same response (prevent email enumeration)
+  res.json({ message: 'If the email exists, a reset link was sent' });
+});
+
+// Step 3: Reset password
+app.post('/auth/reset-password', async (req, res) => {
+  const { token, newPassword } = req.body;
+
+  // Validate password strength
+  if (newPassword.length < 8) {
+    return res.status(400).json({ error: 'Password too short (minimum 8)' });
+  }
+
+  // Check breached passwords (HaveIBeenPwned API)
+  if (await isBreachedPassword(newPassword)) {
+    return res.status(400).json({ error: 'This password has been exposed in a data breach' });
+  }
+
+  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
+  const resetRecord = await db.passwordResets.findOne({
+    tokenHash,
+    used: false,
+    expiresAt: { $gt: new Date() },
+  });
+
+  if (!resetRecord) {
+    return res.status(400).json({ error: 'Invalid or expired reset token' });
+  }
+
+  // Update password
+  const passwordHash = await hashPassword(newPassword);
+  await db.users.update(resetRecord.userId, { passwordHash });
+
+  // Mark token as used
+  await db.passwordResets.update(resetRecord.id, { used: true, usedAt: new Date() });
+
+  // Invalidate all existing sessions
+  await db.sessions.deleteAllForUser(resetRecord.userId);
+
+  // Increment token version to invalidate all JWTs
+  await db.users.increment(resetRecord.userId, 'tokenVersion');
+
+  // Send notification email
+  const user = await db.users.findOne(resetRecord.userId);
+  await sendEmail(user.email, 'Password Changed', {
+    message: 'Your password was changed. If you did not do this, contact support immediately.',
+  });
+
+  res.json({ message: 'Password reset successfully' });
+});
+```
+
+### Breached Password Check (HaveIBeenPwned)
+
+```javascript
+// k-anonymity: only send first 5 chars of SHA-1 hash
+async function isBreachedPassword(password) {
+  const sha1 = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
+  const prefix = sha1.substring(0, 5);
+  const suffix = sha1.substring(5);
+
+  const response = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);
+  const text = await response.text();
+
+  // Check if our suffix appears in the response
+  return text.split('\n').some((line) => {
+    const [hashSuffix] = line.split(':');
+    return hashSuffix.trim() === suffix;
+  });
+}
+```
+
+## Email Verification
+
+```javascript
+// Send verification email on signup
+app.post('/auth/register', async (req, res) => {
+  const { email, password, name } = req.body;
+
+  const passwordHash = await hashPassword(password);
+  const user = await db.users.create({
+    email,
+    passwordHash,
+    name,
+    emailVerified: false,
+  });
+
+  const token = crypto.randomBytes(32).toString('hex');
+  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
+
+  await db.emailVerifications.create({
+    userId: user.id,
+    tokenHash,
+    expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
+  });
+
+  await sendEmail(email, 'Verify your email', {
+    verifyUrl: `https://app.example.com/verify-email?token=${token}`,
+  });
+
+  res.status(201).json({ message: 'Account created. Check your email to verify.' });
+});
+
+// Verify email
+app.get('/auth/verify-email', async (req, res) => {
+  const { token } = req.query;
+  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
+
+  const record = await db.emailVerifications.findOne({
+    tokenHash,
+    expiresAt: { $gt: new Date() },
+  });
+
+  if (!record) {
+    return res.status(400).json({ error: 'Invalid or expired verification link' });
+  }
+
+  await db.users.update(record.userId, { emailVerified: true });
+  await db.emailVerifications.delete(record.id);
+
+  res.json({ message: 'Email verified successfully' });
+});
+
+// Resend verification (rate limited)
+app.post('/auth/resend-verification', requireAuth, async (req, res) => {
+  const rateOk = await checkRateLimit(`verify:${req.auth.sub}`, 3, 3600);
+  if (!rateOk) {
+    return res.status(429).json({ error: 'Too many requests. Try again later.' });
+  }
+
+  // ... generate new token and send email
+  res.json({ message: 'Verification email sent' });
+});
+```
+
+## Magic Links
+
+Passwordless email authentication using one-time login links.
+
+```javascript
+// Request magic link
+app.post('/auth/magic-link', async (req, res) => {
+  const { email } = req.body;
+
+  // Rate limit
+  const rateOk = await checkRateLimit(`magic:${email}`, 5, 3600);
+  if (!rateOk) {
+    return res.json({ message: 'If the email exists, a login link was sent' });
+  }
+
+  const user = await db.findUserByEmail(email);
+
+  if (user) {
+    const token = crypto.randomBytes(32).toString('hex');
+    const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
+
+    // Invalidate any existing magic link tokens
+    await db.magicLinks.deleteForUser(user.id);
+
+    await db.magicLinks.create({
+      userId: user.id,
+      tokenHash,
+      expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes
+      used: false,
+    });
+
+    await sendEmail(email, 'Your login link', {
+      loginUrl: `https://app.example.com/auth/magic-link/verify?token=${token}`,
+      expiresIn: '10 minutes',
+    });
+  }
+
+  // Same response regardless of email existence
+  res.json({ message: 'If the email exists, a login link was sent' });
+});
+
+// Verify magic link
+app.get('/auth/magic-link/verify', async (req, res) => {
+  const { token } = req.query;
+  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
+
+  const record = await db.magicLinks.findOne({
+    tokenHash,
+    used: false,
+    expiresAt: { $gt: new Date() },
+  });
+
+  if (!record) {
+    return res.status(400).json({ error: 'Invalid or expired link' });
+  }
+
+  // Mark as used (single-use)
+  await db.magicLinks.update(record.id, { used: true, usedAt: new Date() });
+
+  // Create session
+  const user = await db.users.findOne(record.userId);
+  const accessToken = await createAccessToken(user);
+
+  res.json({ token: accessToken });
+});
+```
+
+## API Key Management
+
+### Key Generation
+
+```javascript
+// Generate API key with identifiable prefix
+function generateApiKey(prefix = 'sk') {
+  // Format: prefix_randompart
+  // Example: sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
+  const randomPart = crypto.randomBytes(24).toString('base64url');
+  return `${prefix}_${randomPart}`;
+}
+
+// Store: hash the key, keep prefix for identification
+async function createApiKey(userId, name, permissions, expiresIn) {
+  const key = generateApiKey('sk');
+  const prefix = key.substring(0, 8); // "sk_a1b2c"
+  const keyHash = crypto.createHash('sha256').update(key).digest('hex');
+
+  await db.apiKeys.create({
+    userId,
+    name,
+    prefix,
+    keyHash,
+    permissions,  // ['read:data', 'write:data']
+    expiresAt: expiresIn
+      ? new Date(Date.now() + expiresIn)
+      : null,
+    createdAt: new Date(),
+    lastUsedAt: null,
+    revoked: false,
+  });
+
+  // Return the full key ONCE - it cannot be recovered
+  return {
+    key, // Show this to the user once
+    prefix,
+    name,
+    permissions,
+    expiresAt: expiresIn ? new Date(Date.now() + expiresIn) : null,
+  };
+}
+```
+
+### Key Verification
+
+```javascript
+// Verify API key on request
+async function verifyApiKey(apiKey) {
+  const prefix = apiKey.substring(0, 8);
+  const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
+
+  const record = await db.apiKeys.findOne({
+    prefix,
+    revoked: false,
+  });
+
+  if (!record) return null;
+
+  // Constant-time comparison
+  if (!crypto.timingSafeEqual(
+    Buffer.from(keyHash, 'hex'),
+    Buffer.from(record.keyHash, 'hex')
+  )) {
+    return null;
+  }
+
+  // Check expiry
+  if (record.expiresAt && record.expiresAt < new Date()) {
+    return null;
+  }
+
+  // Update last used timestamp (async, don't block response)
+  db.apiKeys.update(record.id, { lastUsedAt: new Date() }).catch(() => {});
+
+  return record;
+}
+```
+
+### Key Rotation
+
+```javascript
+// Rotate API key (create new, keep old active for grace period)
+app.post('/api/keys/:id/rotate', requireAuth, async (req, res) => {
+  const oldKey = await db.apiKeys.findOne({ id: req.params.id, userId: req.auth.sub });
+  if (!oldKey) return res.status(404).json({ error: 'Key not found' });
+
+  // Create new key with same permissions
+  const newKeyResult = await createApiKey(
+    req.auth.sub,
+    `${oldKey.name} (rotated)`,
+    oldKey.permissions,
+    oldKey.expiresAt ? oldKey.expiresAt - Date.now() : null
+  );
+
+  // Mark old key to expire in 24 hours (grace period)
+  await db.apiKeys.update(oldKey.id, {
+    expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
+    rotatedTo: newKeyResult.prefix,
+  });
+
+  res.json({
+    newKey: newKeyResult.key, // Show once
+    oldKeyExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
+    message: 'Old key will remain active for 24 hours',
+  });
+});
+```
+
+## Session Management
+
+### Concurrent Session Handling
+
+```javascript
+// Limit concurrent sessions per user
+const MAX_SESSIONS = 5;
+
+async function createSession(userId, metadata) {
+  const sessions = await db.sessions.findByUser(userId);
+
+  if (sessions.length >= MAX_SESSIONS) {
+    // Remove oldest session
+    const oldest = sessions.sort((a, b) => a.createdAt - b.createdAt)[0];
+    await db.sessions.delete(oldest.id);
+  }
+
+  return db.sessions.create({
+    userId,
+    createdAt: new Date(),
+    lastActiveAt: new Date(),
+    expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
+    ipAddress: metadata.ip,
+    userAgent: metadata.userAgent,
+    deviceInfo: parseUserAgent(metadata.userAgent),
+  });
+}
+```
+
+### Session Listing and Revocation
+
+```javascript
+// List active sessions
+app.get('/auth/sessions', requireAuth, async (req, res) => {
+  const sessions = await db.sessions.findByUser(req.auth.sub);
+
+  res.json(sessions.map((s) => ({
+    id: s.id,
+    current: s.id === req.session.id,
+    device: s.deviceInfo,
+    ipAddress: s.ipAddress,
+    lastActive: s.lastActiveAt,
+    createdAt: s.createdAt,
+  })));
+});
+
+// Revoke a specific session
+app.delete('/auth/sessions/:id', requireAuth, async (req, res) => {
+  const session = await db.sessions.findOne({
+    id: req.params.id,
+    userId: req.auth.sub,
+  });
+
+  if (!session) return res.status(404).json({ error: 'Session not found' });
+
+  await db.sessions.delete(session.id);
+  res.json({ message: 'Session revoked' });
+});
+
+// Revoke all sessions except current
+app.post('/auth/sessions/revoke-all', requireAuth, async (req, res) => {
+  await db.sessions.deleteAllExcept(req.auth.sub, req.session.id);
+  res.json({ message: 'All other sessions revoked' });
+});
+```
+
+## Account Security
+
+### Login Notifications
+
+```javascript
+// Notify user of new login from unrecognized device/location
+async function checkLoginAnomaly(userId, loginMetadata) {
+  const { ip, userAgent, geoLocation } = loginMetadata;
+  const knownDevices = await db.knownDevices.findByUser(userId);
+
+  const deviceFingerprint = crypto
+    .createHash('sha256')
+    .update(`${userAgent}`)
+    .digest('hex');
+
+  const isKnown = knownDevices.some((d) => d.fingerprint === deviceFingerprint);
+
+  if (!isKnown) {
+    const user = await db.users.findOne(userId);
+
+    // Register new device
+    await db.knownDevices.create({
+      userId,
+      fingerprint: deviceFingerprint,
+      userAgent,
+      firstSeen: new Date(),
+      lastSeen: new Date(),
+    });
+
+    // Send notification
+    await sendEmail(user.email, 'New login detected', {
+      device: parseUserAgent(userAgent),
+      location: geoLocation,
+      time: new Date().toISOString(),
+      message: 'If this was not you, change your password immediately.',
+    });
+  }
+}
+```
+
+### Suspicious Activity Detection
+
+```javascript
+// Detect and flag suspicious patterns
+class SecurityMonitor {
+  async checkLogin(userId, metadata) {
+    const flags = [];
+
+    // 1. Impossible travel: login from two distant locations in short time
+    const lastLogin = await db.loginHistory.findLast(userId);
+    if (lastLogin) {
+      const distance = geoDistance(lastLogin.location, metadata.location);
+      const timeDiff = (Date.now() - lastLogin.timestamp) / 1000 / 3600; // hours
+      const maxSpeed = distance / timeDiff; // km/h
+      if (maxSpeed > 1000) { // Faster than commercial flight
+        flags.push('impossible_travel');
+      }
+    }
+
+    // 2. Unusual time: login outside user's normal hours
+    const loginHour = new Date().getHours();
+    const normalHours = await db.users.getNormalLoginHours(userId);
+    if (normalHours && (loginHour < normalHours.start || loginHour > normalHours.end)) {
+      flags.push('unusual_time');
+    }
+
+    // 3. Multiple failed attempts before success
+    const recentFailures = await db.loginAttempts.countRecent(userId, 3600, 'failure');
+    if (recentFailures >= 5) {
+      flags.push('brute_force_attempt');
+    }
+
+    // 4. Known bad IP (threat intelligence)
+    if (await isKnownBadIP(metadata.ip)) {
+      flags.push('suspicious_ip');
+    }
+
+    // Log flags and potentially require step-up auth
+    if (flags.length > 0) {
+      await db.securityEvents.create({
+        userId,
+        event: 'suspicious_login',
+        flags,
+        metadata,
+        timestamp: new Date(),
+      });
+
+      // Require MFA if not already provided
+      if (flags.includes('impossible_travel') || flags.includes('suspicious_ip')) {
+        return { requireMFA: true, flags };
+      }
+    }
+
+    return { requireMFA: false, flags };
+  }
+}
+```
+
+## Timing-Safe Operations
+
+Critical for any comparison involving secrets (tokens, passwords, API keys).
+
+```javascript
+// WRONG: Standard string comparison leaks timing information
+if (providedToken === storedToken) { ... } // VULNERABLE
+
+// CORRECT: Constant-time comparison
+import crypto from 'crypto';
+
+function timingSafeCompare(a, b) {
+  // Both inputs must be same length for timingSafeEqual
+  if (a.length !== b.length) {
+    // Still perform comparison to maintain constant time
+    crypto.timingSafeEqual(Buffer.from(a), Buffer.from(a));
+    return false;
+  }
+  return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
+}
+```
+
+```python
+# Python
+import hmac
+
+# Use hmac.compare_digest for constant-time comparison
+if hmac.compare_digest(provided_token, stored_token):
+    # Valid
+    pass
+```
+
+```go
+// Go
+import "crypto/subtle"
+
+if subtle.ConstantTimeCompare([]byte(provided), []byte(stored)) == 1 {
+    // Valid
+}
+```
+
+## Security Headers for Auth Pages
+
+```javascript
+// Helmet.js or manual headers for auth-related pages
+app.use((req, res, next) => {
+  // Prevent clickjacking
+  res.setHeader('X-Frame-Options', 'DENY');
+  // Prevent MIME sniffing
+  res.setHeader('X-Content-Type-Options', 'nosniff');
+  // Enable HSTS
+  res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
+  // CSP
+  res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'");
+  // Prevent referrer leakage (important for reset tokens in URLs)
+  res.setHeader('Referrer-Policy', 'no-referrer');
+  // Permissions policy
+  res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
+  next();
+});
+```

+ 695 - 0
skills/auth-ops/references/jwt-sessions.md

@@ -0,0 +1,695 @@
+# JWT and Session Management
+
+Deep-dive reference for JSON Web Tokens, session-based authentication, cookie security, and CSRF protection.
+
+## JWT Structure
+
+A JWT consists of three Base64URL-encoded parts separated by dots.
+
+### Header
+
+```json
+{
+  "alg": "RS256",
+  "typ": "JWT",
+  "kid": "key-2024-01"
+}
+```
+
+| Field | Purpose |
+|-------|---------|
+| `alg` | Signing algorithm (RS256, ES256, HS256) |
+| `typ` | Token type (always "JWT") |
+| `kid` | Key ID for key rotation (optional but recommended) |
+
+### Payload (Claims)
+
+#### Registered Claims (RFC 7519)
+
+```json
+{
+  "iss": "https://auth.example.com",
+  "sub": "user_abc123",
+  "aud": "https://api.example.com",
+  "exp": 1700001500,
+  "nbf": 1700000600,
+  "iat": 1700000600,
+  "jti": "unique-token-id-xyz"
+}
+```
+
+| Claim | Required | Purpose |
+|-------|----------|---------|
+| `iss` | Recommended | Identifies the token issuer |
+| `sub` | Recommended | Identifies the subject (user ID) |
+| `aud` | Recommended | Intended recipient(s) of the token |
+| `exp` | Required | Expiration time (Unix timestamp) |
+| `nbf` | Optional | Token not valid before this time |
+| `iat` | Recommended | Time the token was issued |
+| `jti` | Optional | Unique identifier for the token (for revocation) |
+
+#### Custom Claims
+
+```json
+{
+  "role": "admin",
+  "permissions": ["read", "write", "delete"],
+  "org_id": "org_456",
+  "tenant": "acme-corp"
+}
+```
+
+**Guidelines for custom claims:**
+- Namespace custom claims to avoid collisions: `https://example.com/role`
+- Keep payload small (< 1KB) -- JWTs are sent with every request
+- Never put sensitive data in claims (tokens are encoded, not encrypted)
+- Include only what the resource server needs for authorization decisions
+
+### Signature
+
+```
+RSASHA256(
+  base64UrlEncode(header) + "." + base64UrlEncode(payload),
+  privateKey
+)
+```
+
+The signature ensures the token has not been tampered with. Verification uses the public key (asymmetric) or shared secret (symmetric).
+
+## Signing Algorithms
+
+### RS256 (RSA + SHA-256)
+
+**Type:** Asymmetric (public/private key pair)
+**Key size:** 2048 bits minimum (4096 recommended)
+**Use when:** Multiple services verify tokens, auth server is separate from resource servers.
+
+```javascript
+// Node.js (jose library)
+import { SignJWT, jwtVerify, importPKCS8, importSPKI } from 'jose';
+
+// Sign (auth server - has private key)
+const privateKey = await importPKCS8(privateKeyPem, 'RS256');
+const token = await new SignJWT({ sub: 'user_123', role: 'admin' })
+  .setProtectedHeader({ alg: 'RS256', kid: 'key-2024-01' })
+  .setIssuedAt()
+  .setIssuer('https://auth.example.com')
+  .setAudience('https://api.example.com')
+  .setExpirationTime('15m')
+  .sign(privateKey);
+
+// Verify (resource server - has public key only)
+const publicKey = await importSPKI(publicKeyPem, 'RS256');
+const { payload } = await jwtVerify(token, publicKey, {
+  issuer: 'https://auth.example.com',
+  audience: 'https://api.example.com',
+});
+```
+
+```python
+# Python (PyJWT)
+import jwt
+from datetime import datetime, timedelta, timezone
+
+# Sign
+token = jwt.encode(
+    {
+        "sub": "user_123",
+        "role": "admin",
+        "iss": "https://auth.example.com",
+        "aud": "https://api.example.com",
+        "exp": datetime.now(timezone.utc) + timedelta(minutes=15),
+        "iat": datetime.now(timezone.utc),
+    },
+    private_key,
+    algorithm="RS256",
+    headers={"kid": "key-2024-01"},
+)
+
+# Verify
+payload = jwt.decode(
+    token,
+    public_key,
+    algorithms=["RS256"],
+    issuer="https://auth.example.com",
+    audience="https://api.example.com",
+)
+```
+
+```go
+// Go (golang-jwt/jwt/v5)
+import (
+    "time"
+    "github.com/golang-jwt/jwt/v5"
+)
+
+// Sign
+claims := jwt.MapClaims{
+    "sub":  "user_123",
+    "role": "admin",
+    "iss":  "https://auth.example.com",
+    "aud":  "https://api.example.com",
+    "exp":  time.Now().Add(15 * time.Minute).Unix(),
+    "iat":  time.Now().Unix(),
+}
+token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
+token.Header["kid"] = "key-2024-01"
+signedToken, err := token.SignedString(privateKey)
+
+// Verify
+parsedToken, err := jwt.Parse(signedToken, func(t *jwt.Token) (interface{}, error) {
+    if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
+        return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
+    }
+    return publicKey, nil
+}, jwt.WithIssuer("https://auth.example.com"),
+   jwt.WithAudience("https://api.example.com"))
+```
+
+### ES256 (ECDSA + SHA-256)
+
+**Type:** Asymmetric (public/private key pair)
+**Curve:** P-256
+**Use when:** Same as RS256 but smaller tokens and faster signing. Preferred for new systems.
+
+```javascript
+// Node.js (jose)
+import { SignJWT, jwtVerify, importPKCS8, importSPKI } from 'jose';
+
+const privateKey = await importPKCS8(ecPrivateKeyPem, 'ES256');
+const token = await new SignJWT({ sub: 'user_123' })
+  .setProtectedHeader({ alg: 'ES256' })
+  .setExpirationTime('15m')
+  .sign(privateKey);
+```
+
+**ES256 vs RS256:**
+- ES256 signatures: 64 bytes vs RS256: 256 bytes
+- ES256 key generation: faster
+- ES256 signing: faster
+- ES256 verification: slightly slower
+- Both are equally secure for JWT purposes
+
+### HS256 (HMAC + SHA-256)
+
+**Type:** Symmetric (shared secret)
+**Use when:** Single service creates and verifies tokens. Simple internal use.
+
+```javascript
+// Node.js (jose)
+import { SignJWT, jwtVerify } from 'jose';
+
+const secret = new TextEncoder().encode(process.env.JWT_SECRET);
+// Secret must be at least 256 bits (32 bytes) for HS256
+
+const token = await new SignJWT({ sub: 'user_123' })
+  .setProtectedHeader({ alg: 'HS256' })
+  .setExpirationTime('15m')
+  .sign(secret);
+
+const { payload } = await jwtVerify(token, secret);
+```
+
+**Warning:** With HS256, anyone who can verify tokens can also create them. Never use HS256 when the verifier should not be able to issue tokens.
+
+### Algorithm Selection Matrix
+
+| Factor | HS256 | RS256 | ES256 |
+|--------|-------|-------|-------|
+| Key type | Shared secret | RSA key pair | EC key pair |
+| Token size | Smallest | Largest | Medium |
+| Sign speed | Fast | Slow | Fast |
+| Verify speed | Fast | Fast | Medium |
+| Key distribution | Secret must be shared | Only public key shared | Only public key shared |
+| Best for | Single service | Distributed, legacy | Distributed, modern |
+
+## Access + Refresh Token Pattern
+
+### Flow
+
+1. User authenticates (login with credentials, OAuth2, etc.)
+2. Auth server issues access token (short-lived) and refresh token (long-lived)
+3. Client uses access token for API requests via `Authorization: Bearer <token>`
+4. When access token expires, client sends refresh token to get new tokens
+5. Auth server validates refresh token, issues new access + refresh tokens
+6. Old refresh token is invalidated (rotation)
+
+### Token Lifetimes
+
+| Token | Lifetime | Storage |
+|-------|----------|---------|
+| Access token | 5-15 minutes | Memory (SPA), httpOnly cookie (BFF) |
+| Refresh token | 7-30 days | httpOnly cookie, secure storage (mobile) |
+
+### Refresh Token Rotation
+
+```javascript
+// Auth server: refresh endpoint
+app.post('/auth/refresh', async (req, res) => {
+  const { refreshToken } = req.cookies;
+
+  // 1. Look up the refresh token
+  const storedToken = await db.refreshTokens.findOne({
+    token: hash(refreshToken),
+  });
+
+  if (!storedToken) {
+    // Token not found - might be reuse of revoked token
+    // Revoke entire token family as precaution
+    await db.refreshTokens.deleteMany({ family: storedToken?.family });
+    return res.status(401).json({ error: 'Invalid refresh token' });
+  }
+
+  if (storedToken.revoked) {
+    // Reuse detected! Revoke entire family
+    await db.refreshTokens.deleteMany({ family: storedToken.family });
+    return res.status(401).json({ error: 'Token reuse detected' });
+  }
+
+  if (storedToken.expiresAt < new Date()) {
+    return res.status(401).json({ error: 'Refresh token expired' });
+  }
+
+  // 2. Revoke the old refresh token
+  await db.refreshTokens.updateOne(
+    { token: hash(refreshToken) },
+    { revoked: true }
+  );
+
+  // 3. Issue new tokens
+  const newAccessToken = await createAccessToken(storedToken.userId);
+  const newRefreshToken = crypto.randomBytes(32).toString('hex');
+
+  // 4. Store new refresh token in same family
+  await db.refreshTokens.insertOne({
+    token: hash(newRefreshToken),
+    userId: storedToken.userId,
+    family: storedToken.family,  // Same family for reuse detection
+    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
+    revoked: false,
+  });
+
+  // 5. Return new tokens
+  res.cookie('refreshToken', newRefreshToken, {
+    httpOnly: true,
+    secure: true,
+    sameSite: 'strict',
+    maxAge: 7 * 24 * 60 * 60 * 1000,
+    path: '/auth/refresh',  // Only sent to refresh endpoint
+  });
+
+  res.json({ accessToken: newAccessToken });
+});
+```
+
+### Token Family Detection
+
+Token families track lineage of refresh tokens. If a revoked refresh token is reused (indicating theft), all tokens in the family are invalidated.
+
+```
+Login → RT1 (family: F1)
+    RT1 → RT2 (family: F1, RT1 revoked)
+        RT2 → RT3 (family: F1, RT2 revoked)
+
+If attacker uses stolen RT1:
+    RT1 is revoked → ALERT → revoke all in family F1
+    User must re-authenticate
+```
+
+## Token Revocation Strategies
+
+### Strategy 1: Short Expiry + No Revocation
+
+- Access tokens expire in 5-15 minutes
+- No revocation mechanism needed
+- Revoke refresh token to prevent renewal
+- **Trade-off:** Cannot immediately invalidate access tokens
+
+### Strategy 2: Blocklist (Redis)
+
+```javascript
+// Add to blocklist on logout/revocation
+await redis.set(`blocklist:${jti}`, '1', 'EX', tokenRemainingTTL);
+
+// Check on every request
+const isRevoked = await redis.get(`blocklist:${jti}`);
+if (isRevoked) return res.status(401).json({ error: 'Token revoked' });
+```
+
+- Entries auto-expire when the token would have expired
+- **Trade-off:** Requires Redis, adds latency to every request
+
+### Strategy 3: Version-Based Revocation
+
+```javascript
+// User record has a tokenVersion
+// JWT includes tokenVersion claim
+// On password change/logout-all: increment tokenVersion
+// On verification: compare JWT version with stored version
+
+const user = await db.users.findOne({ id: payload.sub });
+if (payload.tokenVersion !== user.tokenVersion) {
+  return res.status(401).json({ error: 'Token revoked' });
+}
+```
+
+- Revokes all tokens for a user at once
+- **Trade-off:** Requires DB lookup per request (but can cache)
+
+## Session-Based Authentication
+
+### Server-Side Sessions
+
+```javascript
+// Express + express-session + connect-redis
+import session from 'express-session';
+import RedisStore from 'connect-redis';
+import { createClient } from 'redis';
+
+const redisClient = createClient({ url: process.env.REDIS_URL });
+await redisClient.connect();
+
+app.use(session({
+  store: new RedisStore({ client: redisClient }),
+  name: '__Host-session',           // Cookie name with secure prefix
+  secret: process.env.SESSION_SECRET,
+  resave: false,
+  saveUninitialized: false,
+  cookie: {
+    secure: true,                   // HTTPS only
+    httpOnly: true,                 // No JS access
+    sameSite: 'lax',                // CSRF protection
+    maxAge: 24 * 60 * 60 * 1000,    // 24 hours
+    path: '/',
+  },
+  rolling: true,                    // Reset expiry on each request
+}));
+```
+
+```python
+# FastAPI + Redis sessions
+from fastapi import FastAPI, Request, Response
+from uuid import uuid4
+import redis.asyncio as redis
+import json
+
+r = redis.from_url("redis://localhost:6379")
+
+async def create_session(response: Response, user_id: str, data: dict):
+    session_id = str(uuid4())
+    session_data = {"user_id": user_id, **data}
+    await r.setex(f"session:{session_id}", 86400, json.dumps(session_data))
+
+    response.set_cookie(
+        key="__Host-session",
+        value=session_id,
+        httponly=True,
+        secure=True,
+        samesite="lax",
+        max_age=86400,
+        path="/",
+    )
+    return session_id
+
+async def get_session(request: Request) -> dict | None:
+    session_id = request.cookies.get("__Host-session")
+    if not session_id:
+        return None
+    data = await r.get(f"session:{session_id}")
+    if data:
+        # Reset TTL (sliding window)
+        await r.expire(f"session:{session_id}", 86400)
+        return json.loads(data)
+    return None
+```
+
+### Session Storage Backends
+
+| Backend | Scalability | Persistence | Latency | Use When |
+|---------|-------------|-------------|---------|----------|
+| Memory | Single server | None (lost on restart) | Fastest | Development only |
+| Redis | Horizontal | Optional (AOF/RDB) | ~1ms | Production default |
+| PostgreSQL | Horizontal | Full | ~5ms | Already using Postgres, need durability |
+| MongoDB | Horizontal | Full | ~3ms | Already using MongoDB |
+
+### Session Fixation Prevention
+
+Always regenerate the session ID after authentication state changes:
+
+```javascript
+// Express
+app.post('/login', async (req, res) => {
+  const user = await authenticate(req.body.email, req.body.password);
+  if (!user) return res.status(401).json({ error: 'Invalid credentials' });
+
+  // CRITICAL: Regenerate session ID to prevent fixation
+  req.session.regenerate((err) => {
+    if (err) return res.status(500).json({ error: 'Session error' });
+    req.session.userId = user.id;
+    req.session.role = user.role;
+    req.session.save((err) => {
+      if (err) return res.status(500).json({ error: 'Session error' });
+      res.json({ user: { id: user.id, email: user.email } });
+    });
+  });
+});
+```
+
+## Cookie Security
+
+### Cookie Attributes Deep Dive
+
+#### SameSite
+
+| Value | Behavior | CSRF Protection | Use Case |
+|-------|----------|-----------------|----------|
+| `Strict` | Cookie never sent cross-site | Strongest | Internal tools, admin panels |
+| `Lax` | Sent on top-level navigation (GET) | Good (default) | General-purpose sessions |
+| `None` | Sent on all cross-site requests | None (requires `Secure`) | Embedded widgets, cross-origin APIs |
+
+**Lax vs Strict:** Lax allows the session cookie to be sent when a user clicks a link to your site from an external page. Strict does not, so users would appear logged out after clicking a link from an email or social media.
+
+#### Secure Prefix Cookies
+
+```
+// __Host- prefix (strictest, recommended)
+Set-Cookie: __Host-session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/
+
+// Requirements for __Host-:
+// - Must have Secure flag
+// - Must NOT have Domain attribute
+// - Must have Path=/
+// - Only sent to exact host (no subdomains)
+
+// __Secure- prefix (less strict)
+Set-Cookie: __Secure-session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/
+
+// Requirements for __Secure-:
+// - Must have Secure flag
+// - Can have Domain attribute
+```
+
+Use `__Host-` prefix for session cookies. It prevents a subdomain takeover from overwriting your session cookie.
+
+### Cookie vs Authorization Header
+
+| Aspect | Cookie | Authorization Header |
+|--------|--------|---------------------|
+| Automatic sending | Yes (browser sends automatically) | No (must attach manually) |
+| CSRF risk | Yes (unless SameSite) | No |
+| XSS theft risk | No (if HttpOnly) | Yes (if in accessible storage) |
+| Cross-origin | Configurable (SameSite, CORS) | Simple (just add header) |
+| Best for | Server-rendered apps, BFF | Pure APIs, mobile apps |
+
+## CSRF Protection
+
+### Synchronizer Token Pattern
+
+```javascript
+// Generate CSRF token and store in session
+import crypto from 'crypto';
+
+app.use((req, res, next) => {
+  if (!req.session.csrfToken) {
+    req.session.csrfToken = crypto.randomBytes(32).toString('hex');
+  }
+  res.locals.csrfToken = req.session.csrfToken;
+  next();
+});
+
+// Validate on state-changing requests
+app.use((req, res, next) => {
+  if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
+    const token = req.headers['x-csrf-token'] || req.body._csrf;
+    if (!token || token !== req.session.csrfToken) {
+      return res.status(403).json({ error: 'Invalid CSRF token' });
+    }
+  }
+  next();
+});
+```
+
+### Double-Submit Cookie Pattern
+
+```javascript
+// Set CSRF token as a separate cookie (NOT httpOnly, so JS can read it)
+res.cookie('csrf-token', csrfToken, {
+  secure: true,
+  sameSite: 'strict',
+  // httpOnly: false -- intentionally readable by JS
+  path: '/',
+});
+
+// Client reads cookie value and sends in header
+// fetch('/api/data', {
+//   method: 'POST',
+//   headers: { 'X-CSRF-Token': getCookie('csrf-token') },
+// });
+
+// Server validates: cookie value === header value
+```
+
+### SameSite as Defense-in-Depth
+
+`SameSite=Lax` prevents most CSRF attacks because the cookie is not sent on cross-site POST requests. However, it does not protect against:
+- Subdomain attacks
+- GET-based state changes (which you should not have)
+- Browser bugs
+
+**Recommendation:** Use `SameSite=Lax` AND a CSRF token for defense-in-depth.
+
+## Stateless vs Stateful Authentication
+
+| Aspect | Stateless (JWT) | Stateful (Sessions) |
+|--------|-----------------|---------------------|
+| **Server storage** | None (token is self-contained) | Session store (Redis, DB) |
+| **Scalability** | Easy (any server can verify) | Requires shared session store |
+| **Revocation** | Hard (need blocklist) | Easy (delete session) |
+| **Token size** | Larger (contains claims) | Smaller (just session ID) |
+| **Offline verification** | Yes (with public key) | No (must query session store) |
+| **Information leakage** | Claims visible (base64) | Server-side only |
+| **Performance** | No DB lookup for verification | DB/cache lookup per request |
+| **Logout** | Complex (blocklist or wait for expiry) | Simple (delete session) |
+| **Best for** | Microservices, APIs, mobile | Monoliths, server-rendered apps |
+
+### Hybrid Approach
+
+Many production systems use both:
+
+```
+User login → Session created (server-side)
+           → JWT issued for API calls
+           → Session manages refresh tokens
+           → JWT used for stateless API authorization
+```
+
+## Token Storage for SPAs
+
+### Option 1: BFF Pattern (Recommended)
+
+```
+Browser ←→ BFF (Backend-for-Frontend) ←→ API
+  │                  │
+  │ session cookie   │ JWT in Authorization header
+  │ (httpOnly)       │ (server-to-server)
+```
+
+The BFF holds tokens server-side and proxies API calls. The browser only has a session cookie.
+
+### Option 2: HttpOnly Cookie
+
+Access token stored in httpOnly cookie. Requires CSRF protection. Works well for same-origin APIs.
+
+### Option 3: In-Memory (JavaScript Variable)
+
+Access token stored in a JavaScript variable. Lost on page refresh (must re-authenticate via refresh token in httpOnly cookie). Safest browser storage for tokens but impacts UX.
+
+### What NOT to Do
+
+| Storage | Problem |
+|---------|---------|
+| localStorage | Accessible via XSS, persists across tabs |
+| sessionStorage | Accessible via XSS |
+| Non-httpOnly cookie | Accessible via XSS |
+| URL parameters | Logged by servers, proxies, browser history |
+
+## Key Rotation
+
+### Why Rotate Keys
+
+- Limit exposure if a key is compromised
+- Compliance requirements
+- Cryptographic best practice
+
+### Rotation Process
+
+```
+1. Generate new key pair (kid: "key-2025-01")
+2. Add new key to JWKS endpoint
+3. Start signing new tokens with new key
+4. Old tokens still verify (old key still in JWKS)
+5. After max token lifetime, remove old key from JWKS
+```
+
+### JWKS (JSON Web Key Set) Endpoint
+
+```json
+// GET /.well-known/jwks.json
+{
+  "keys": [
+    {
+      "kty": "RSA",
+      "kid": "key-2025-01",
+      "use": "sig",
+      "alg": "RS256",
+      "n": "...",
+      "e": "AQAB"
+    },
+    {
+      "kty": "RSA",
+      "kid": "key-2024-01",
+      "use": "sig",
+      "alg": "RS256",
+      "n": "...",
+      "e": "AQAB"
+    }
+  ]
+}
+```
+
+```javascript
+// Verify JWT with JWKS (jose library)
+import { createRemoteJWKSet, jwtVerify } from 'jose';
+
+const JWKS = createRemoteJWKSet(
+  new URL('https://auth.example.com/.well-known/jwks.json')
+);
+
+const { payload } = await jwtVerify(token, JWKS, {
+  issuer: 'https://auth.example.com',
+  audience: 'https://api.example.com',
+});
+```
+
+## JWT Validation Checklist
+
+Every JWT verification should check:
+
+- [ ] **Signature** is valid
+- [ ] **Algorithm** matches expected (prevent `alg: none` attack)
+- [ ] **Expiration** (`exp`) has not passed
+- [ ] **Not Before** (`nbf`) has passed (if present)
+- [ ] **Issuer** (`iss`) matches expected value
+- [ ] **Audience** (`aud`) matches your service
+- [ ] **Token type** is correct (access vs refresh)
+- [ ] **Key ID** (`kid`) maps to a known key
+
+### Common JWT Attacks
+
+| Attack | Description | Prevention |
+|--------|-------------|------------|
+| `alg: none` | Attacker removes signature | Always validate alg against allowlist |
+| Key confusion (RS256→HS256) | Attacker signs with public key as HMAC secret | Explicitly specify expected algorithm |
+| Token substitution | Access token used as refresh (or vice versa) | Include token type in claims |
+| JWK injection | Attacker includes key in JWT header | Only trust keys from your JWKS endpoint |
+| Expired token replay | Attacker replays old token | Always validate `exp` claim |

+ 1014 - 0
skills/auth-ops/references/oauth2-oidc.md

@@ -0,0 +1,1014 @@
+# OAuth2 and OpenID Connect
+
+Comprehensive reference for OAuth2 grant types, OIDC, provider integration, and social login.
+
+## OAuth2 Core Concepts
+
+### Roles
+
+| Role | Description | Example |
+|------|-------------|---------|
+| **Resource Owner** | The user who owns the data | End user |
+| **Client** | The application requesting access | Your web/mobile app |
+| **Authorization Server** | Issues tokens after authentication | Auth0, Keycloak, your auth service |
+| **Resource Server** | Hosts the protected API | Your API server |
+
+### Key Terms
+
+| Term | Description |
+|------|-------------|
+| **Scope** | Permission level requested (e.g., `read:users`, `write:posts`) |
+| **Grant Type** | The flow used to obtain tokens |
+| **Authorization Code** | Temporary code exchanged for tokens |
+| **Access Token** | Token used to call the API |
+| **Refresh Token** | Token used to get new access tokens |
+| **Redirect URI** | Where the authorization server sends the user back |
+| **State** | CSRF protection parameter (random, unguessable) |
+| **PKCE** | Proof Key for Code Exchange (prevents code interception) |
+
+## Authorization Code + PKCE
+
+The recommended flow for web applications, mobile apps, and SPAs. PKCE (Proof Key for Code Exchange) protects against authorization code interception.
+
+### Flow
+
+```
+┌──────┐          ┌───────────────┐          ┌──────────────┐
+│Client│          │ Authorization │          │   Resource   │
+│      │          │    Server     │          │    Server    │
+└──┬───┘          └───────┬───────┘          └──────┬───────┘
+   │                      │                         │
+   │ 1. Generate code_verifier (random)             │
+   │    code_challenge = SHA256(code_verifier)       │
+   │                      │                         │
+   │ 2. Redirect to /authorize                      │
+   │    ?response_type=code                         │
+   │    &client_id=xxx                              │
+   │    &redirect_uri=https://app/callback          │
+   │    &scope=openid profile email                 │
+   │    &state=random_csrf_value                    │
+   │    &code_challenge=xxx                         │
+   │    &code_challenge_method=S256                 │
+   │──────────────>│                                │
+   │               │                                │
+   │ 3. User authenticates and consents             │
+   │               │                                │
+   │ 4. Redirect to callback                        │
+   │    ?code=authorization_code                    │
+   │    &state=random_csrf_value                    │
+   │<──────────────│                                │
+   │                                                │
+   │ 5. POST /token                                 │
+   │    grant_type=authorization_code               │
+   │    &code=authorization_code                    │
+   │    &redirect_uri=https://app/callback          │
+   │    &client_id=xxx                              │
+   │    &code_verifier=original_random_value        │
+   │──────────────>│                                │
+   │               │                                │
+   │ 6. Response: access_token, refresh_token,      │
+   │    id_token (if OIDC)                          │
+   │<──────────────│                                │
+   │                                                │
+   │ 7. GET /api/resource                           │
+   │    Authorization: Bearer access_token          │
+   │────────────────────────────────────────────────>│
+   │                                                │
+   │ 8. Response: protected resource                │
+   │<────────────────────────────────────────────────│
+```
+
+### Implementation: Node.js
+
+```javascript
+import crypto from 'crypto';
+
+// Step 1: Generate PKCE values
+function generatePKCE() {
+  const verifier = crypto.randomBytes(32).toString('base64url');
+  const challenge = crypto
+    .createHash('sha256')
+    .update(verifier)
+    .digest('base64url');
+  return { verifier, challenge };
+}
+
+// Step 2: Build authorization URL
+function getAuthorizationUrl(config) {
+  const { verifier, challenge } = generatePKCE();
+  const state = crypto.randomBytes(16).toString('hex');
+
+  // Store verifier and state in session
+  // req.session.pkceVerifier = verifier;
+  // req.session.oauthState = state;
+
+  const params = new URLSearchParams({
+    response_type: 'code',
+    client_id: config.clientId,
+    redirect_uri: config.redirectUri,
+    scope: 'openid profile email',
+    state,
+    code_challenge: challenge,
+    code_challenge_method: 'S256',
+  });
+
+  return `${config.authorizationEndpoint}?${params}`;
+}
+
+// Step 5: Exchange code for tokens
+async function exchangeCode(code, verifier, config) {
+  const response = await fetch(config.tokenEndpoint, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+    body: new URLSearchParams({
+      grant_type: 'authorization_code',
+      code,
+      redirect_uri: config.redirectUri,
+      client_id: config.clientId,
+      client_secret: config.clientSecret, // Confidential clients only
+      code_verifier: verifier,
+    }),
+  });
+
+  if (!response.ok) {
+    throw new Error(`Token exchange failed: ${response.status}`);
+  }
+
+  return response.json();
+  // Returns: { access_token, refresh_token, id_token, token_type, expires_in }
+}
+```
+
+### Implementation: Python
+
+```python
+import hashlib
+import secrets
+import base64
+from urllib.parse import urlencode
+import httpx
+
+def generate_pkce():
+    verifier = secrets.token_urlsafe(32)
+    challenge = base64.urlsafe_b64encode(
+        hashlib.sha256(verifier.encode()).digest()
+    ).rstrip(b"=").decode()
+    return verifier, challenge
+
+def get_authorization_url(config: dict) -> tuple[str, str, str]:
+    verifier, challenge = generate_pkce()
+    state = secrets.token_hex(16)
+
+    params = urlencode({
+        "response_type": "code",
+        "client_id": config["client_id"],
+        "redirect_uri": config["redirect_uri"],
+        "scope": "openid profile email",
+        "state": state,
+        "code_challenge": challenge,
+        "code_challenge_method": "S256",
+    })
+
+    url = f"{config['authorization_endpoint']}?{params}"
+    return url, verifier, state
+
+async def exchange_code(code: str, verifier: str, config: dict) -> dict:
+    async with httpx.AsyncClient() as client:
+        response = await client.post(
+            config["token_endpoint"],
+            data={
+                "grant_type": "authorization_code",
+                "code": code,
+                "redirect_uri": config["redirect_uri"],
+                "client_id": config["client_id"],
+                "client_secret": config["client_secret"],
+                "code_verifier": verifier,
+            },
+        )
+        response.raise_for_status()
+        return response.json()
+```
+
+### Redirect URI Validation
+
+**Critical security requirement:** The authorization server must validate redirect URIs exactly.
+
+| Rule | Why |
+|------|-----|
+| Exact match required | Prevents open redirect attacks |
+| No wildcards in production | Attacker could register matching subdomain |
+| HTTPS required | Prevent code interception on HTTP |
+| No fragments (#) | Fragment not sent to server |
+| Pre-register all URIs | Only allow known, trusted redirect targets |
+
+### State Parameter
+
+The `state` parameter prevents CSRF attacks on the OAuth2 flow:
+
+```javascript
+// Before redirect: generate and store
+const state = crypto.randomBytes(16).toString('hex');
+req.session.oauthState = state;
+
+// In callback: validate
+if (req.query.state !== req.session.oauthState) {
+  throw new Error('State mismatch - possible CSRF attack');
+}
+delete req.session.oauthState;
+```
+
+## Client Credentials Grant
+
+Server-to-server authentication with no user context.
+
+```
+┌──────────┐                    ┌───────────────┐
+│  Service  │                    │ Authorization │
+│  Client   │                    │    Server     │
+└─────┬─────┘                    └───────┬───────┘
+      │                                  │
+      │  POST /token                     │
+      │  grant_type=client_credentials   │
+      │  &client_id=xxx                  │
+      │  &client_secret=yyy             │
+      │  &scope=read:data               │
+      │─────────────────────────────────>│
+      │                                  │
+      │  { access_token, expires_in }    │
+      │<─────────────────────────────────│
+```
+
+```javascript
+// Node.js implementation
+async function getClientCredentialsToken(config) {
+  const response = await fetch(config.tokenEndpoint, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/x-www-form-urlencoded',
+      Authorization: `Basic ${Buffer.from(
+        `${config.clientId}:${config.clientSecret}`
+      ).toString('base64')}`,
+    },
+    body: new URLSearchParams({
+      grant_type: 'client_credentials',
+      scope: config.scope,
+    }),
+  });
+
+  const data = await response.json();
+
+  // Cache the token until near expiry
+  // tokenCache.set(cacheKey, data.access_token, data.expires_in - 60);
+
+  return data.access_token;
+}
+```
+
+```python
+# Python implementation
+async def get_client_credentials_token(config: dict) -> str:
+    async with httpx.AsyncClient() as client:
+        response = await client.post(
+            config["token_endpoint"],
+            auth=(config["client_id"], config["client_secret"]),
+            data={
+                "grant_type": "client_credentials",
+                "scope": config["scope"],
+            },
+        )
+        response.raise_for_status()
+        data = response.json()
+        return data["access_token"]
+```
+
+**Best practices:**
+- Cache tokens until near expiry (subtract 60 seconds from `expires_in`)
+- Use mutual TLS (mTLS) for additional security in high-trust environments
+- Rotate client secrets periodically
+
+## Device Code Grant
+
+For CLI tools, smart TVs, and devices without a browser or with limited input.
+
+```
+┌──────────┐          ┌───────────────┐          ┌──────────┐
+│  Device   │          │ Authorization │          │  User's  │
+│ (CLI/TV)  │          │    Server     │          │ Browser  │
+└─────┬─────┘          └───────┬───────┘          └────┬─────┘
+      │                        │                       │
+      │ POST /device/code      │                       │
+      │ client_id=xxx          │                       │
+      │ scope=profile          │                       │
+      │───────────────────────>│                       │
+      │                        │                       │
+      │ { device_code,         │                       │
+      │   user_code: "ABCD-1234",                      │
+      │   verification_uri,    │                       │
+      │   interval: 5 }        │                       │
+      │<───────────────────────│                       │
+      │                        │                       │
+      │ Display to user:       │                       │
+      │ "Visit https://auth.example.com/device"        │
+      │ "Enter code: ABCD-1234"│                       │
+      │                        │                       │
+      │                        │  User visits URL      │
+      │                        │  and enters code      │
+      │                        │<──────────────────────│
+      │                        │                       │
+      │                        │  User authenticates   │
+      │                        │  and authorizes       │
+      │                        │<──────────────────────│
+      │                        │                       │
+      │ Poll: POST /token      │                       │
+      │ grant_type=urn:ietf:   │                       │
+      │   params:oauth:        │                       │
+      │   grant-type:device_code                       │
+      │ device_code=xxx        │                       │
+      │───────────────────────>│                       │
+      │                        │                       │
+      │ { access_token }       │                       │
+      │<───────────────────────│                       │
+```
+
+```javascript
+// CLI implementation
+async function deviceCodeFlow(config) {
+  // 1. Request device code
+  const codeResponse = await fetch(`${config.authServer}/device/code`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+    body: new URLSearchParams({
+      client_id: config.clientId,
+      scope: 'openid profile',
+    }),
+  });
+
+  const { device_code, user_code, verification_uri, interval } =
+    await codeResponse.json();
+
+  // 2. Display to user
+  console.log(`Visit: ${verification_uri}`);
+  console.log(`Enter code: ${user_code}`);
+
+  // 3. Poll for completion
+  while (true) {
+    await new Promise((r) => setTimeout(r, interval * 1000));
+
+    const tokenResponse = await fetch(`${config.authServer}/token`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+      body: new URLSearchParams({
+        grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
+        device_code,
+        client_id: config.clientId,
+      }),
+    });
+
+    const data = await tokenResponse.json();
+
+    if (data.error === 'authorization_pending') continue;
+    if (data.error === 'slow_down') {
+      interval += 5;
+      continue;
+    }
+    if (data.error) throw new Error(data.error_description);
+
+    return data; // { access_token, refresh_token, ... }
+  }
+}
+```
+
+## Token Exchange (RFC 8693)
+
+Allows a service to exchange one token for another, maintaining user context across microservices.
+
+```javascript
+// Service A has user's token, needs to call Service B
+async function exchangeToken(userToken, targetAudience, config) {
+  const response = await fetch(config.tokenEndpoint, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+    body: new URLSearchParams({
+      grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
+      subject_token: userToken,
+      subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
+      audience: targetAudience, // Service B's identifier
+      scope: 'read:data',
+    }),
+  });
+
+  return response.json();
+  // Returns token with `act` claim showing delegation chain:
+  // { "sub": "user_123", "act": { "sub": "service_a" } }
+}
+```
+
+## OpenID Connect (OIDC)
+
+OIDC is an identity layer on top of OAuth2. While OAuth2 handles authorization (access to resources), OIDC handles authentication (who the user is).
+
+### What OIDC Adds to OAuth2
+
+| OAuth2 Only | OIDC Adds |
+|-------------|-----------|
+| Access token (opaque) | ID token (JWT with user info) |
+| Resource access | User identity |
+| Scopes for permissions | Standard identity scopes |
+| No user info standard | UserInfo endpoint |
+| No discovery | `.well-known/openid-configuration` |
+
+### ID Token
+
+The ID token is a JWT containing user identity information.
+
+```json
+{
+  "iss": "https://auth.example.com",
+  "sub": "user_abc123",
+  "aud": "client_id_xyz",
+  "exp": 1700001500,
+  "iat": 1700000600,
+  "nonce": "random_nonce_value",
+  "auth_time": 1700000500,
+  "name": "Alice Smith",
+  "email": "alice@example.com",
+  "email_verified": true,
+  "picture": "https://example.com/alice.jpg"
+}
+```
+
+### Standard OIDC Scopes
+
+| Scope | Claims Returned |
+|-------|----------------|
+| `openid` | `sub` (required scope for OIDC) |
+| `profile` | `name`, `family_name`, `given_name`, `picture`, `locale` |
+| `email` | `email`, `email_verified` |
+| `address` | `address` (structured object) |
+| `phone` | `phone_number`, `phone_number_verified` |
+
+### Discovery Document
+
+```
+GET https://auth.example.com/.well-known/openid-configuration
+```
+
+```json
+{
+  "issuer": "https://auth.example.com",
+  "authorization_endpoint": "https://auth.example.com/authorize",
+  "token_endpoint": "https://auth.example.com/token",
+  "userinfo_endpoint": "https://auth.example.com/userinfo",
+  "jwks_uri": "https://auth.example.com/.well-known/jwks.json",
+  "scopes_supported": ["openid", "profile", "email"],
+  "response_types_supported": ["code"],
+  "grant_types_supported": ["authorization_code", "client_credentials"],
+  "subject_types_supported": ["public"],
+  "id_token_signing_alg_values_supported": ["RS256", "ES256"],
+  "code_challenge_methods_supported": ["S256"]
+}
+```
+
+### UserInfo Endpoint
+
+```javascript
+// Fetch additional user info
+const userInfo = await fetch('https://auth.example.com/userinfo', {
+  headers: { Authorization: `Bearer ${accessToken}` },
+}).then((r) => r.json());
+
+// Response:
+// {
+//   "sub": "user_abc123",
+//   "name": "Alice Smith",
+//   "email": "alice@example.com",
+//   "email_verified": true,
+//   "picture": "https://example.com/alice.jpg"
+// }
+```
+
+## Provider Integration
+
+### Auth0
+
+```javascript
+// Next.js with Auth0 SDK
+// npm install @auth0/nextjs-auth0
+
+// app/api/auth/[auth0]/route.ts
+import { handleAuth } from '@auth0/nextjs-auth0';
+export const GET = handleAuth();
+
+// app/layout.tsx
+import { UserProvider } from '@auth0/nextjs-auth0/client';
+export default function RootLayout({ children }) {
+  return <UserProvider>{children}</UserProvider>;
+}
+
+// Protected page
+import { withPageAuthRequired, getSession } from '@auth0/nextjs-auth0';
+export default withPageAuthRequired(async function Dashboard() {
+  const session = await getSession();
+  return <div>Welcome {session.user.name}</div>;
+});
+
+// API route protection
+import { withApiAuthRequired, getSession } from '@auth0/nextjs-auth0';
+export const GET = withApiAuthRequired(async (req) => {
+  const session = await getSession();
+  return Response.json({ user: session.user });
+});
+```
+
+### Clerk
+
+```javascript
+// Next.js with Clerk
+// npm install @clerk/nextjs
+
+// middleware.ts
+import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
+
+const isProtectedRoute = createRouteMatcher(['/dashboard(.*)']);
+
+export default clerkMiddleware(async (auth, request) => {
+  if (isProtectedRoute(request)) {
+    await auth.protect();
+  }
+});
+
+// app/layout.tsx
+import { ClerkProvider } from '@clerk/nextjs';
+export default function RootLayout({ children }) {
+  return <ClerkProvider>{children}</ClerkProvider>;
+}
+
+// Components
+import { SignIn, SignUp, UserButton } from '@clerk/nextjs';
+// <SignIn /> - full sign-in component
+// <UserButton /> - user avatar with dropdown
+```
+
+### Supabase Auth
+
+```javascript
+// Supabase Auth with Row Level Security
+import { createClient } from '@supabase/supabase-js';
+
+const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
+
+// Sign up
+const { data, error } = await supabase.auth.signUp({
+  email: 'user@example.com',
+  password: 'secure-password',
+});
+
+// Sign in
+const { data, error } = await supabase.auth.signInWithPassword({
+  email: 'user@example.com',
+  password: 'secure-password',
+});
+
+// OAuth (Google)
+const { data, error } = await supabase.auth.signInWithOAuth({
+  provider: 'google',
+  options: { redirectTo: 'https://app.example.com/callback' },
+});
+
+// Get current session
+const { data: { session } } = await supabase.auth.getSession();
+
+// RLS policy (in PostgreSQL)
+// CREATE POLICY "Users can read own data"
+// ON profiles FOR SELECT
+// USING (auth.uid() = user_id);
+```
+
+### AWS Cognito
+
+```javascript
+// AWS Cognito with Amplify
+import { Amplify } from 'aws-amplify';
+import { signIn, signUp, getCurrentUser } from 'aws-amplify/auth';
+
+Amplify.configure({
+  Auth: {
+    Cognito: {
+      userPoolId: 'us-east-1_xxxxx',
+      userPoolClientId: 'xxxxx',
+      loginWith: {
+        oauth: {
+          domain: 'auth.example.com',
+          scopes: ['openid', 'profile', 'email'],
+          redirectSignIn: ['https://app.example.com/callback'],
+          redirectSignOut: ['https://app.example.com/'],
+          responseType: 'code',
+        },
+      },
+    },
+  },
+});
+
+const { isSignedIn } = await signIn({
+  username: 'user@example.com',
+  password: 'secure-password',
+});
+```
+
+### Keycloak
+
+```javascript
+// Keycloak with keycloak-js
+import Keycloak from 'keycloak-js';
+
+const keycloak = new Keycloak({
+  url: 'https://keycloak.example.com',
+  realm: 'my-realm',
+  clientId: 'my-app',
+});
+
+await keycloak.init({
+  onLoad: 'check-sso',
+  pkceMethod: 'S256',
+});
+
+if (keycloak.authenticated) {
+  const token = keycloak.token;
+  const userInfo = await keycloak.loadUserInfo();
+}
+
+// Token refresh
+keycloak.onTokenExpired = () => {
+  keycloak.updateToken(30).catch(() => keycloak.login());
+};
+```
+
+## Social Login
+
+### Google
+
+```javascript
+// Google OAuth2 specifics
+const googleConfig = {
+  authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
+  tokenEndpoint: 'https://oauth2.googleapis.com/token',
+  scopes: 'openid email profile',
+  // Quirks:
+  // - Use `prompt=consent` to force consent screen (get refresh token)
+  // - Use `access_type=offline` for refresh tokens
+  // - Google ID tokens include `hd` (hosted domain) for Google Workspace
+};
+```
+
+### GitHub
+
+```javascript
+// GitHub OAuth2 specifics
+const githubConfig = {
+  authorizationEndpoint: 'https://github.com/login/oauth/authorize',
+  tokenEndpoint: 'https://github.com/login/oauth/access_token',
+  userEndpoint: 'https://api.github.com/user',
+  emailEndpoint: 'https://api.github.com/user/emails',
+  // Quirks:
+  // - No OIDC support (no ID token)
+  // - Must fetch user info separately
+  // - Email may be private; use /user/emails endpoint
+  // - Token endpoint returns form-encoded by default
+  //   (set Accept: application/json header)
+  // - No refresh tokens (tokens don't expire unless revoked)
+};
+```
+
+### Apple
+
+```javascript
+// Apple Sign In specifics
+const appleConfig = {
+  authorizationEndpoint: 'https://appleid.apple.com/auth/authorize',
+  tokenEndpoint: 'https://appleid.apple.com/auth/token',
+  // Quirks:
+  // - Client secret is a JWT signed with your Apple private key
+  // - User info (name, email) only returned on FIRST sign-in
+  //   (must store it immediately)
+  // - Users can hide email (relay address)
+  // - Must validate ID token, Apple doesn't have UserInfo endpoint
+  // - response_mode=form_post for web
+};
+
+// Generate Apple client secret (JWT)
+import { SignJWT, importPKCS8 } from 'jose';
+
+async function generateAppleClientSecret(config) {
+  const privateKey = await importPKCS8(config.privateKey, 'ES256');
+
+  return new SignJWT({})
+    .setProtectedHeader({ alg: 'ES256', kid: config.keyId })
+    .setIssuer(config.teamId)
+    .setSubject(config.clientId)
+    .setAudience('https://appleid.apple.com')
+    .setIssuedAt()
+    .setExpirationTime('180d')
+    .sign(privateKey);
+}
+```
+
+## Scope Design
+
+### Naming Conventions
+
+```
+# Resource-based (recommended)
+read:users
+write:users
+delete:users
+admin:users
+
+# Action-based
+users.read
+users.write
+users.delete
+
+# Hierarchical (coarse to fine)
+users           # Full access to users
+users:read      # Read-only access
+users:profile   # Access to profile only
+```
+
+### Scope Design Principles
+
+| Principle | Description |
+|-----------|-------------|
+| Least privilege | Request only needed scopes |
+| Granularity balance | Too fine = user confusion, too coarse = over-permission |
+| Hierarchical | Broader scope implies narrower ones |
+| Descriptive | Scope name should be self-explanatory |
+| Documented | Each scope has a user-facing description |
+
+### Consent Management
+
+```javascript
+// Scope validation middleware
+function requireScopes(...requiredScopes) {
+  return (req, res, next) => {
+    const tokenScopes = req.auth.scope?.split(' ') || [];
+    const hasAll = requiredScopes.every((s) => tokenScopes.includes(s));
+    if (!hasAll) {
+      return res.status(403).json({
+        error: 'insufficient_scope',
+        required: requiredScopes,
+        granted: tokenScopes,
+      });
+    }
+    next();
+  };
+}
+
+// Usage
+app.get('/api/users', requireScopes('read:users'), getUsers);
+app.post('/api/users', requireScopes('write:users'), createUser);
+app.delete('/api/users/:id', requireScopes('delete:users'), deleteUser);
+```
+
+## Token Lifecycle
+
+### Token Endpoint Responses
+
+```json
+// Successful token response
+{
+  "access_token": "eyJhbGciOi...",
+  "token_type": "Bearer",
+  "expires_in": 900,
+  "refresh_token": "dGhpcyBpcyBh...",
+  "id_token": "eyJhbGciOi...",
+  "scope": "openid profile email"
+}
+
+// Error response
+{
+  "error": "invalid_grant",
+  "error_description": "The authorization code has expired"
+}
+```
+
+### Token Introspection (RFC 7662)
+
+Allows a resource server to check if a token is still valid (useful for opaque tokens).
+
+```javascript
+// Resource server checks token validity
+async function introspectToken(token, config) {
+  const response = await fetch(config.introspectionEndpoint, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/x-www-form-urlencoded',
+      Authorization: `Basic ${Buffer.from(
+        `${config.clientId}:${config.clientSecret}`
+      ).toString('base64')}`,
+    },
+    body: new URLSearchParams({
+      token,
+      token_type_hint: 'access_token',
+    }),
+  });
+
+  const data = await response.json();
+  // { active: true, sub: "user_123", scope: "read:users", exp: 1700001500 }
+  // { active: false } -- token is invalid/expired/revoked
+  return data;
+}
+```
+
+### Token Revocation (RFC 7009)
+
+```javascript
+// Revoke a token (on logout)
+async function revokeToken(token, tokenType, config) {
+  await fetch(config.revocationEndpoint, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/x-www-form-urlencoded',
+      Authorization: `Basic ${Buffer.from(
+        `${config.clientId}:${config.clientSecret}`
+      ).toString('base64')}`,
+    },
+    body: new URLSearchParams({
+      token,
+      token_type_hint: tokenType, // 'access_token' or 'refresh_token'
+    }),
+  });
+  // Always returns 200 (even if token was already invalid)
+}
+```
+
+## Implementation Libraries
+
+### Auth.js (NextAuth.js)
+
+```javascript
+// app/api/auth/[...nextauth]/route.ts
+import NextAuth from 'next-auth';
+import Google from 'next-auth/providers/google';
+import GitHub from 'next-auth/providers/github';
+import Credentials from 'next-auth/providers/credentials';
+
+export const { handlers, auth, signIn, signOut } = NextAuth({
+  providers: [
+    Google({
+      clientId: process.env.GOOGLE_CLIENT_ID,
+      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
+    }),
+    GitHub({
+      clientId: process.env.GITHUB_CLIENT_ID,
+      clientSecret: process.env.GITHUB_CLIENT_SECRET,
+    }),
+    Credentials({
+      credentials: {
+        email: { label: 'Email' },
+        password: { label: 'Password', type: 'password' },
+      },
+      authorize: async (credentials) => {
+        const user = await verifyCredentials(
+          credentials.email,
+          credentials.password
+        );
+        return user || null;
+      },
+    }),
+  ],
+  callbacks: {
+    async jwt({ token, user, account }) {
+      if (user) {
+        token.role = user.role;
+      }
+      return token;
+    },
+    async session({ session, token }) {
+      session.user.role = token.role;
+      return session;
+    },
+  },
+  pages: {
+    signIn: '/login',
+    error: '/auth/error',
+  },
+});
+```
+
+### Passport.js (Express)
+
+```javascript
+import passport from 'passport';
+import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
+
+passport.use(
+  new GoogleStrategy(
+    {
+      clientID: process.env.GOOGLE_CLIENT_ID,
+      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
+      callbackURL: '/auth/google/callback',
+    },
+    async (accessToken, refreshToken, profile, done) => {
+      const user = await db.users.upsert({
+        where: { googleId: profile.id },
+        create: {
+          googleId: profile.id,
+          email: profile.emails[0].value,
+          name: profile.displayName,
+        },
+        update: { name: profile.displayName },
+      });
+      done(null, user);
+    }
+  )
+);
+
+// Routes
+app.get('/auth/google', passport.authenticate('google', {
+  scope: ['profile', 'email'],
+}));
+
+app.get('/auth/google/callback',
+  passport.authenticate('google', { failureRedirect: '/login' }),
+  (req, res) => res.redirect('/dashboard')
+);
+```
+
+### Python: Authlib / python-social-auth
+
+```python
+# FastAPI with Authlib
+from authlib.integrations.starlette_client import OAuth
+from starlette.config import Config
+
+oauth = OAuth()
+oauth.register(
+    name="google",
+    server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
+    client_id=config("GOOGLE_CLIENT_ID"),
+    client_secret=config("GOOGLE_CLIENT_SECRET"),
+    client_kwargs={"scope": "openid email profile"},
+)
+
+@app.get("/auth/google")
+async def google_login(request: Request):
+    redirect_uri = request.url_for("google_callback")
+    return await oauth.google.authorize_redirect(request, redirect_uri)
+
+@app.get("/auth/google/callback")
+async def google_callback(request: Request):
+    token = await oauth.google.authorize_access_token(request)
+    userinfo = token.get("userinfo")
+    # Create or update user, establish session
+    return RedirectResponse(url="/dashboard")
+```
+
+### Go: golang.org/x/oauth2
+
+```go
+import (
+    "golang.org/x/oauth2"
+    "golang.org/x/oauth2/google"
+)
+
+var googleOAuthConfig = &oauth2.Config{
+    ClientID:     os.Getenv("GOOGLE_CLIENT_ID"),
+    ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
+    RedirectURL:  "https://app.example.com/auth/google/callback",
+    Scopes:       []string{"openid", "profile", "email"},
+    Endpoint:     google.Endpoint,
+}
+
+func handleGoogleLogin(w http.ResponseWriter, r *http.Request) {
+    state := generateRandomState() // Store in session
+    url := googleOAuthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline)
+    http.Redirect(w, r, url, http.StatusTemporaryRedirect)
+}
+
+func handleGoogleCallback(w http.ResponseWriter, r *http.Request) {
+    // Validate state parameter
+    code := r.URL.Query().Get("code")
+    token, err := googleOAuthConfig.Exchange(r.Context(), code)
+    if err != nil {
+        http.Error(w, "Token exchange failed", http.StatusInternalServerError)
+        return
+    }
+
+    // Use token to get user info
+    client := googleOAuthConfig.Client(r.Context(), token)
+    resp, _ := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
+    // Parse response, create/update user, establish session
+}
+```
+
+## Deprecated: Implicit Grant
+
+The OAuth2 Implicit grant (`response_type=token`) is **deprecated** and should not be used for new applications.
+
+**Why it was deprecated:**
+- Access token exposed in URL fragment (browser history, referer headers)
+- No refresh tokens (user must re-authenticate)
+- No mechanism to verify the token was intended for your client
+- Vulnerable to token injection attacks
+
+**Migration:** Use Authorization Code + PKCE instead, with a BFF for SPAs.

+ 0 - 0
skills/auth-ops/scripts/.gitkeep


+ 277 - 0
skills/debug-ops/SKILL.md

@@ -0,0 +1,277 @@
+---
+name: debug-ops
+description: "Systematic debugging methodology, language-specific debuggers, and common scenario playbooks. Use for: debug, debugging, bug, crash, hang, memory leak, race condition, deadlock, bisect, reproduce, root cause, breakpoint, profiling, performance issue, segfault, stack trace, core dump."
+allowed-tools: "Read Write Bash"
+related-skills: [testing-ops, security-ops, monitoring-ops, code-stats]
+---
+
+# Debug Operations
+
+Systematic debugging methodology with language-specific tooling and common scenario playbooks.
+
+## Bug Classification Decision Tree
+
+```
+Bug Report / Symptom
+│
+├─ Crash
+│  ├─ Segfault / Access Violation
+│  │  └─ Check: null pointer, buffer overflow, use-after-free, stack overflow
+│  ├─ Panic / Fatal Error
+│  │  └─ Check: assertion failure, unrecoverable state, out-of-memory
+│  └─ Unhandled Exception
+│     └─ Check: missing error handler, unexpected input type, network failure
+│
+├─ Hang
+│  ├─ Deadlock
+│  │  └─ Check: lock ordering, mutex contention, channel blocking
+│  ├─ Infinite Loop
+│  │  └─ Check: loop termination condition, counter overflow, recursive call
+│  └─ Blocked I/O
+│     └─ Check: network timeout, DNS resolution, disk full, file lock
+│
+├─ Wrong Output
+│  ├─ Logic Error
+│  │  └─ Check: operator precedence, boundary conditions, boolean logic
+│  ├─ Data Corruption
+│  │  └─ Check: concurrent mutation, encoding mismatch, truncation
+│  └─ Off-by-One
+│     └─ Check: loop bounds, array indexing, fence-post errors
+│
+├─ Performance
+│  ├─ Slow Queries
+│  │  └─ Check: missing index, N+1 queries, full table scan, lock wait
+│  ├─ Memory Bloat
+│  │  └─ Check: cache without eviction, leaked references, large allocations
+│  └─ CPU Spikes
+│     └─ Check: hot loops, regex backtracking, excessive GC, busy-wait
+│
+└─ Intermittent
+   ├─ Race Condition
+   │  └─ Check: shared mutable state, read-modify-write, check-then-act
+   ├─ Timing-Dependent
+   │  └─ Check: timeout values, clock skew, event ordering assumptions
+   └─ Environment-Specific
+      └─ Check: OS differences, locale, timezone, file system case sensitivity
+```
+
+## Systematic Debugging Workflow
+
+Six-step process from symptom to prevention:
+
+### Step 1: Reproduce
+
+Confirm the bug exists and create a reliable reproduction. A bug you cannot reproduce is a bug you cannot confidently fix. Capture exact inputs, environment, and sequence of operations.
+
+### Step 2: Isolate
+
+Narrow the fault to the smallest possible scope. Use binary search (git bisect, commenting out code halves), stubs, feature flags, and environment isolation to eliminate innocent code.
+
+### Step 3: Identify
+
+Find the root cause, not just the proximate trigger. Use the 5 Whys technique, trace execution, inspect state at key points. Distinguish between the symptom and the underlying defect.
+
+### Step 4: Fix
+
+Apply the minimal correct change that addresses the root cause. Avoid shotgun debugging (changing multiple things at once). Understand why the fix works, not just that it works.
+
+### Step 5: Verify
+
+Confirm the fix resolves the original issue without introducing regressions. Re-run the original reproduction case. Run the full test suite. Test edge cases related to the fix.
+
+### Step 6: Prevent
+
+Add a regression test. Update documentation or runbooks if applicable. Consider whether the same class of bug could exist elsewhere. Share findings with the team.
+
+## Reproduction Checklist
+
+```
+[ ] Minimal reproduction steps documented (numbered, unambiguous)
+[ ] Environment captured (OS, runtime version, dependencies, config)
+[ ] Exact inputs recorded (request payload, CLI args, file contents)
+[ ] Timing sensitivity assessed (does it fail only under load? after delay?)
+[ ] Single-threaded reproduction attempted (eliminates concurrency noise)
+[ ] Reproduction automated as script or test case
+[ ] Confirmed reproduction is deterministic (fails N/N attempts)
+[ ] Identified whether reproduction requires specific data/state
+```
+
+## Isolation Techniques Quick Reference
+
+| Technique | Method | Best For |
+|-----------|--------|----------|
+| **Binary search (git)** | `git bisect start BAD GOOD` then `git bisect run ./test.sh` | Finding which commit introduced the bug |
+| **Binary search (code)** | Comment out half the code, test, repeat | Narrowing fault location in unfamiliar code |
+| **Stubs/Mocks** | Replace dependencies with known-good fakes | Isolating from external services |
+| **Feature flags** | Toggle features off one by one | Finding which feature causes the issue |
+| **Environment isolation** | Docker container, fresh VM, clean install | Eliminating environment contamination |
+| **Network interception** | mitmproxy, Charles Proxy, mock server | Isolating client vs server issues |
+| **Input reduction** | Remove input fields/data until bug disappears | Finding minimal trigger |
+| **Dependency pinning** | Lock all deps, update one at a time | Finding breaking dependency update |
+
+## Root Cause Analysis Template
+
+### 5 Whys Example
+
+```
+Problem: API returns 500 error on user login
+
+1. Why? → The database query throws a timeout exception
+2. Why? → The users table scan takes >30 seconds
+3. Why? → There is no index on the email column
+4. Why? → The migration that adds the index was never run in production
+5. Why? → The deployment script skips migrations when the --fast flag is used
+
+Root cause: Deployment script's --fast flag bypasses migrations
+Fix: Remove --fast flag behavior that skips migrations, add migration check to health endpoint
+Prevention: CI check that verifies all migrations are applied after deployment
+```
+
+### Fault Tree Basics
+
+```
+                    [System Failure]
+                    /              \
+            [Hardware]          [Software]
+            /       \           /        \
+       [Disk]    [Memory]  [Config]   [Code Bug]
+                              |          |
+                         [Missing    [Race in
+                          env var]    worker pool]
+```
+
+Work from the top (observed failure) down to leaves (root causes). Each branch is an AND/OR gate -- AND means all children must be true, OR means any one child suffices.
+
+## Language-Specific Debugger Quick Reference
+
+| Language | Tool | Launch Command | Key Commands |
+|----------|------|----------------|--------------|
+| **Node.js** | Chrome DevTools | `node --inspect-brk app.js` | Open `chrome://inspect`, set breakpoints in Sources |
+| **Node.js** | ndb | `npx ndb app.js` | Enhanced DevTools with blackboxing |
+| **Python** | pdb | `python -m pdb script.py` | `n` next, `s` step, `c` continue, `p expr` print, `bt` backtrace |
+| **Python** | debugpy | `python -m debugpy --listen 5678 --wait-for-client script.py` | VS Code "Attach" launch config |
+| **Python** | breakpoint() | Insert `breakpoint()` in code | Drops into pdb at that line (Python 3.7+) |
+| **Go** | Delve | `dlv debug ./cmd/server` | `b main.go:42` break, `c` continue, `n` next, `p var` print |
+| **Go** | Delve (test) | `dlv test ./pkg/...` | Debug test functions directly |
+| **Go** | Delve (attach) | `dlv attach PID` | Debug running process |
+| **Rust** | rust-gdb | `rust-gdb target/debug/myapp` | `b main`, `r`, `n`, `p variable`, `bt` |
+| **Rust** | rust-lldb | `rust-lldb target/debug/myapp` | `b s main`, `r`, `n`, `p variable`, `bt` |
+| **Rust** | CodeLLDB | VS Code extension | GUI breakpoints, variable inspection |
+| **Browser** | DevTools | F12 or Ctrl+Shift+I | Elements, Console, Network, Sources, Performance, Memory |
+
+### Quick Debug Snippets
+
+```javascript
+// Node.js: drop into debugger at this point
+debugger;
+
+// Node.js: conditional breakpoint
+if (user.id === 'problem-user') debugger;
+```
+
+```python
+# Python: drop into debugger at this point
+breakpoint()
+
+# Python: conditional breakpoint
+if user_id == 'problem-user':
+    breakpoint()
+```
+
+```go
+// Go: print goroutine stacks (send SIGQUIT or SIGABRT)
+// kill -QUIT <pid>
+// Or in code:
+import "runtime/debug"
+debug.PrintStack()
+```
+
+```rust
+// Rust: enable full backtraces
+// RUST_BACKTRACE=1 cargo run
+// RUST_BACKTRACE=full cargo run
+```
+
+## Log-Based Debugging Patterns
+
+### Strategic Logging
+
+Place logs at decision points, not just error paths:
+
+```
+[ENTRY] function_name(args_summary)     -- entering the function
+[STATE] key_variable=value              -- state at critical decision point
+[BRANCH] taking path X because Y       -- which branch and why
+[EXIT] function_name -> result_summary  -- leaving the function
+[ERROR] operation failed: detail        -- error with context
+```
+
+### Correlation IDs
+
+Trace a single request across services:
+
+```bash
+# Generate at entry point, propagate through all calls
+X-Request-ID: 550e8400-e29b-41d4-a716-446655440000
+
+# Search across all service logs
+rg "550e8400-e29b-41d4-a716-446655440000" /var/log/services/
+```
+
+### Timeline Reconstruction
+
+```bash
+# Merge and sort logs from multiple sources by timestamp
+sort -t' ' -k1,2 service-a.log service-b.log service-c.log > timeline.log
+
+# Find gaps in activity (potential hang/block)
+awk '{print $1, $2}' timeline.log | uniq -c | sort -rn | head -20
+```
+
+### Structured Log Queries
+
+```bash
+# jq queries on JSON logs
+# Find all errors for a specific user
+jq 'select(.level == "error" and .user_id == "u123")' app.log
+
+# Get timing distribution for slow requests
+jq 'select(.duration_ms > 1000) | .duration_ms' app.log | sort -n
+
+# Count errors by type
+jq -r 'select(.level == "error") | .error_type' app.log | sort | uniq -c | sort -rn
+```
+
+## Common Gotchas
+
+| Gotcha | Why It Hurts | Fix |
+|--------|-------------|-----|
+| Fixing symptoms, not root cause | Bug resurfaces in a different form | Use 5 Whys to dig deeper |
+| Debugging in production without safety net | Risk of data loss or extended outage | Use read-only queries, feature flags, canary deploys |
+| Heisenbug (disappears under observation) | Adding logging/breakpoints changes timing | Use non-invasive tools: `strace`, sampling profiler, `rr` |
+| Assumption bias ("it can't be X") | Skipping the actual cause because you trust it | Test every assumption explicitly, even "obvious" ones |
+| Missing reproduction case | Cannot verify fix, cannot prevent regression | Invest time upfront in reliable reproduction |
+| Over-relying on print/log debugging | Slow iteration, pollutes code, misses concurrency bugs | Use proper debugger, profiler, or tracing tool |
+| Not checking recent changes | The answer is often in the last few commits | `git log --oneline -20`, `git diff HEAD~5` |
+| Ignoring warning messages | Warnings often predict the error that follows | Treat warnings as errors during debugging |
+| Debugging wrong version/branch | Wasting time on already-fixed or different code | Verify `git branch`, `git log -1`, runtime version |
+| Not reading the full stack trace | Root cause is often in the middle, not the top | Read bottom-up: find your code in the trace first |
+| Changing multiple things at once | Cannot tell which change fixed (or broke) it | One change per test cycle |
+| Not capturing the "before" state | Cannot diff against working baseline | Snapshot config, deps, data before debugging |
+
+## Reference Files
+
+| File | Contents | Lines |
+|------|----------|-------|
+| `references/systematic-methods.md` | Scientific method, binary search, delta debugging, differential debugging, time-travel debugging, team debugging | ~600 |
+| `references/tool-specific.md` | Browser DevTools, Node.js, Python, Go, Rust, database, network, Docker debugging tools | ~650 |
+| `references/common-scenarios.md` | Memory leaks, deadlocks, race conditions, performance regressions, API debugging, deployment issues | ~550 |
+
+## See Also
+
+- **testing-ops** -- Write tests to prevent bugs from recurring
+- **security-ops** -- Security-specific debugging (auth failures, injection, CSRF)
+- **monitoring-ops** -- Production observability, alerting, dashboards
+- **code-stats** -- Measure code complexity and identify bug-prone areas
+- **container-orchestration** -- Docker and Kubernetes debugging context
+- **git-workflow** -- Git bisect workflow and history investigation

+ 0 - 0
skills/debug-ops/assets/.gitkeep


+ 769 - 0
skills/debug-ops/references/common-scenarios.md

@@ -0,0 +1,769 @@
+# Common Debugging Scenarios
+
+Playbooks for the most frequently encountered bug categories.
+
+## Memory Leaks
+
+### Symptoms
+
+```
+├─ RSS (Resident Set Size) grows continuously over time
+├─ OOM (Out of Memory) kills after hours/days of uptime
+├─ Increasing GC time / GC pauses getting longer
+├─ Application slows down gradually
+└─ Swap usage increases
+```
+
+### Browser / Frontend
+
+Three-snapshot technique:
+
+```
+1. Take heap snapshot (baseline after page load)
+2. Perform the suspected leaking action (e.g., open/close modal 10 times)
+3. Force garbage collection (Performance panel → trash can icon)
+4. Take heap snapshot 2
+5. Repeat step 2 (10 more times)
+6. Force GC again
+7. Take heap snapshot 3
+8. In snapshot 3, select "Objects allocated between snapshot 1 and 2"
+9. Sort by "Retained Size" descending
+10. Look for objects that should have been GC'd
+```
+
+Detached DOM nodes:
+
+```javascript
+// Find detached DOM nodes in DevTools Console
+// Take heap snapshot → search for "Detached" in class filter
+
+// Common cause: event listener on removed element
+const handler = () => { /* ... */ };
+element.addEventListener('click', handler);
+element.remove(); // Element is detached but handler holds reference
+
+// Fix: remove listener before removing element
+element.removeEventListener('click', handler);
+element.remove();
+
+// Or use AbortController (modern approach)
+const controller = new AbortController();
+element.addEventListener('click', handler, { signal: controller.signal });
+// Later: clean up all listeners at once
+controller.abort();
+```
+
+### Node.js
+
+```bash
+# Method 1: Chrome DevTools
+node --inspect app.js
+# Open chrome://inspect → Take heap snapshots
+
+# Method 2: heapdump module
+# In code: require('heapdump');
+# Send SIGUSR2 to take snapshot: kill -USR2 PID
+# Compare .heapsnapshot files in Chrome DevTools
+
+# Method 3: clinic.js doctor
+clinic doctor -- node app.js
+# Generates report identifying likely memory leak
+
+# Method 4: Process memory monitoring
+node -e "setInterval(() => console.log(process.memoryUsage()), 5000)"
+# Watch rss, heapUsed, heapTotal, external, arrayBuffers
+```
+
+### Python
+
+```python
+# objgraph: find reference chains keeping objects alive
+import objgraph
+
+# Show object count growth between two points
+objgraph.show_growth(limit=10)
+# ... run suspect code ...
+objgraph.show_growth(limit=10)  # Shows what increased
+
+# Find what holds a reference to an object
+objgraph.show_backrefs(
+    objgraph.by_type('MyClass')[0],
+    max_depth=5,
+    filename='refs.png'
+)
+
+# tracemalloc: track where allocations happen
+import tracemalloc
+tracemalloc.start(25)  # Store 25 frames of traceback
+
+# ... run suspect code ...
+
+snapshot = tracemalloc.take_snapshot()
+for stat in snapshot.statistics('traceback')[:5]:
+    print(stat)
+    for line in stat.traceback.format():
+        print(f"  {line}")
+
+# gc: inspect garbage collector
+import gc
+gc.set_debug(gc.DEBUG_LEAK)  # Log uncollectable objects
+gc.collect()  # Force collection
+print(gc.garbage)  # List of uncollectable objects
+
+# Find circular references
+gc.collect()
+for obj in gc.garbage:
+    print(type(obj), gc.get_referrers(obj))
+```
+
+### Go
+
+```bash
+# Enable pprof endpoint (add to your app)
+# import _ "net/http/pprof"
+
+# Take heap profile
+go tool pprof http://localhost:6060/debug/pprof/heap
+
+# Compare two heap profiles (before and after)
+go tool pprof -diff_base=heap1.prof heap2.prof
+
+# Inside pprof:
+(pprof) top             # Top allocators
+(pprof) top -cum        # Top by cumulative allocations
+(pprof) list funcName   # Annotated source showing allocations per line
+(pprof) web             # Graphical view in browser
+
+# Quick check: runtime memory stats
+import "runtime"
+
+var m runtime.MemStats
+runtime.ReadMemStats(&m)
+fmt.Printf("Alloc: %d MiB\n", m.Alloc / 1024 / 1024)
+fmt.Printf("TotalAlloc: %d MiB\n", m.TotalAlloc / 1024 / 1024)
+fmt.Printf("Sys: %d MiB\n", m.Sys / 1024 / 1024)
+fmt.Printf("NumGC: %d\n", m.NumGC)
+```
+
+### Common Causes
+
+| Cause | Language | Detection |
+|-------|----------|-----------|
+| Event listener accumulation | JS | Heap snapshot → EventListener count growing |
+| Cache without eviction | All | Memory grows linearly with unique inputs |
+| Closure capturing large scope | JS/Python | Heap snapshot → large retained size in closures |
+| Circular references | Python | `gc.garbage` shows uncollectable objects |
+| Goroutine leak | Go | `pprof/goroutine` count grows over time |
+| Global/static collections | All | Check module-level lists, dicts, maps |
+| Unreleased database connections | All | Connection pool stats show exhaustion |
+| String concatenation in loops | Go/Java | `strings.Builder` / `StringBuilder` instead |
+| Forgotten timers/intervals | JS | `setInterval` without corresponding `clearInterval` |
+
+## Deadlocks
+
+### Symptoms
+
+```
+├─ Process hangs (0% CPU, still alive)
+├─ All worker threads blocked
+├─ No new log output
+├─ Health check timeouts
+└─ Incoming requests queue up, never complete
+```
+
+### Detection by Language
+
+```bash
+# Go: dump all goroutine stacks
+kill -SIGQUIT PID
+# Or: curl http://localhost:6060/debug/pprof/goroutine?debug=2
+
+# Java: thread dump
+jstack PID
+kill -3 PID  # SIGQUIT also works for JVM
+
+# Python: faulthandler (prints all thread stacks)
+python -c "import faulthandler; faulthandler.enable()" # then Ctrl+\
+# Or send SIGUSR1 if faulthandler is registered
+
+# Node.js: get active handles/requests
+process._getActiveHandles()
+process._getActiveRequests()
+
+# Linux: check what threads are waiting on
+cat /proc/PID/stack           # Kernel stack of main thread
+ls /proc/PID/task/            # List all threads
+cat /proc/PID/task/TID/stack  # Kernel stack of specific thread
+
+# GDB: attach to stuck process
+gdb -p PID
+(gdb) info threads
+(gdb) thread apply all bt    # Backtrace for all threads
+```
+
+### Classic Deadlock Pattern
+
+```
+Thread 1: lock(A) → lock(B)
+Thread 2: lock(B) → lock(A)
+
+Timeline:
+  T1: acquires A         T2: acquires B
+  T1: waits for B        T2: waits for A
+  → DEADLOCK (both waiting forever)
+```
+
+### Prevention
+
+```
+1. Consistent lock ordering:
+   Always acquire locks in the same order (e.g., alphabetical by resource name)
+
+2. Timeout on lock acquisition:
+   mutex.tryLock(timeout: 5.seconds)
+   If timeout → release all locks, backoff, retry
+
+3. Lock-free data structures:
+   Use atomic operations, channels (Go), or concurrent collections
+
+4. Detect and break:
+   Deadlock detection thread that monitors lock wait times
+   Go: runtime detects goroutine deadlocks (fatal error: all goroutines asleep)
+```
+
+### Go-Specific: Channel Deadlocks
+
+```go
+// Deadlock: unbuffered channel with no receiver
+ch := make(chan int)
+ch <- 1  // blocks forever, no goroutine reading
+
+// Deadlock: channel in select without default
+select {
+case msg := <-ch:
+    process(msg)
+// no default → blocks forever if ch has no sender
+}
+
+// Fix: add timeout or default
+select {
+case msg := <-ch:
+    process(msg)
+case <-time.After(5 * time.Second):
+    log.Println("timeout waiting for message")
+default:
+    // non-blocking
+}
+
+// Goroutine leak detection
+// If goroutine count grows over time, goroutines are stuck
+import "runtime"
+fmt.Println("Goroutines:", runtime.NumGoroutine())
+```
+
+### Go-Specific: Mutex Deadlock Detection
+
+```go
+// Use sync.Mutex with deadlock detector during development
+// go get github.com/sasha-s/go-deadlock
+import "github.com/sasha-s/go-deadlock"
+
+var mu go_deadlock.Mutex  // Drop-in replacement for sync.Mutex
+// Prints potential deadlock warning with stack traces
+// when lock is held for too long
+```
+
+## Race Conditions
+
+### Symptoms
+
+```
+├─ Intermittent test failures ("flaky tests")
+├─ Different results on different runs with same input
+├─ Bug disappears when adding print/log statements (Heisenbug)
+├─ Works with 1 user, fails with 10 concurrent users
+├─ Works in debugger, fails in production
+└─ Data corruption that "should be impossible"
+```
+
+### Detection Tools
+
+```bash
+# Go: built-in race detector
+go test -race ./...
+go run -race ./cmd/server
+
+# C/C++/Rust: ThreadSanitizer
+# Compile with: -fsanitize=thread
+gcc -fsanitize=thread -g program.c -o program
+./program
+
+# Rust: Miri (for unsafe code)
+cargo miri test
+
+# Java: use -XX:+UseThreadSanitizer (experimental)
+# or tools like FindBugs, SpotBugs with concurrency detectors
+
+# Python: threading issues are less common due to GIL
+# but still occur with multiprocessing, asyncio, or C extensions
+```
+
+### Reproduction Techniques
+
+```python
+# Technique 1: Add strategic delays to widen the race window
+import time
+
+def transfer(from_account, to_account, amount):
+    balance = from_account.balance
+    time.sleep(0.001)  # ← Widens the race window
+    from_account.balance = balance - amount
+    time.sleep(0.001)  # ← Makes race more likely
+    to_account.balance += amount
+```
+
+```bash
+# Technique 2: Increase concurrency
+# Run the same test with 100 concurrent workers
+for i in $(seq 1 100); do
+    curl -s http://localhost:3000/api/transfer &
+done
+wait
+
+# Technique 3: Stress test with loop
+for i in $(seq 1 1000); do
+    go test -race -count=1 ./pkg/... || echo "FAILED on iteration $i"
+done
+```
+
+```go
+// Technique 4: Go - use t.Parallel() in tests
+func TestConcurrentAccess(t *testing.T) {
+    for i := 0; i < 100; i++ {
+        t.Run(fmt.Sprintf("case_%d", i), func(t *testing.T) {
+            t.Parallel() // Run sub-tests concurrently
+            // ... test code that exercises shared state
+        })
+    }
+}
+```
+
+### Common Race Condition Patterns
+
+**Read-Modify-Write (most common)**:
+
+```
+Thread 1: read counter (= 5)
+Thread 2: read counter (= 5)
+Thread 1: write counter (= 6)
+Thread 2: write counter (= 6)  ← Should be 7!
+
+Fix: atomic operations or mutex
+  Go:    atomic.AddInt64(&counter, 1)
+  Rust:  counter.fetch_add(1, Ordering::SeqCst)
+  JS:    N/A (single-threaded, but async read-modify-write exists)
+  Python: threading.Lock()
+```
+
+**Check-Then-Act (TOCTOU)**:
+
+```
+Thread 1: if file.exists()        (yes)
+Thread 2:                         delete file
+Thread 1:     file.read()         ← CRASH: file no longer exists
+
+Fix: atomic operations or locks
+  OS-level: use O_CREAT|O_EXCL flags
+  DB-level: use transactions with proper isolation
+  App-level: lock around check+act
+```
+
+**Publication Without Synchronization**:
+
+```go
+// BAD: other goroutines may see partially initialized Config
+config = &Config{Host: "example.com", Port: 8080}
+
+// GOOD: use atomic.Value or sync.Once
+var configValue atomic.Value
+configValue.Store(&Config{Host: "example.com", Port: 8080})
+```
+
+## Performance Regressions
+
+### Detection
+
+```bash
+# Git bisect with benchmark
+git bisect start
+git bisect bad HEAD
+git bisect good v1.0.0
+
+# Automated bisect using benchmark threshold
+cat > /tmp/bench-test.sh << 'SCRIPT'
+#!/bin/bash
+go test -bench=BenchmarkCriticalPath -count=5 ./pkg/... |
+  grep "ns/op" |
+  awk '{print $3}' |
+  awk '{s+=$1; n++} END {
+    avg = s/n;
+    if (avg > 1000) exit 1;  # Bad if > 1000 ns/op
+    else exit 0;              # Good otherwise
+  }'
+SCRIPT
+chmod +x /tmp/bench-test.sh
+git bisect run /tmp/bench-test.sh
+```
+
+### CPU Profiling
+
+```bash
+# Go: CPU profile
+go test -cpuprofile=cpu.prof -bench=. ./pkg/...
+go tool pprof cpu.prof
+(pprof) top 20
+(pprof) list HotFunction  # Annotated source with time per line
+(pprof) web               # Visual graph
+
+# Node.js: clinic flame
+clinic flame -- node app.js
+# Or: 0x app.js
+
+# Python: cProfile
+python -m cProfile -s cumulative script.py
+# Or: py-spy for live profiling
+py-spy top --pid PID
+
+# Rust: cargo flamegraph
+cargo flamegraph --bin myapp
+```
+
+### Flame Graph Interpretation
+
+```
+Reading flame graphs:
+├─ X-axis: fraction of total time (wider = more time)
+├─ Y-axis: call stack depth (bottom = entry point, top = leaf)
+├─ Each bar: a function in the stack
+├─ Color: usually random (not meaningful) unless semantic coloring
+│
+├─ Look for: "plateaus" (wide flat bars) = hot functions
+├─ Look for: unexpected depth = unnecessary call chains
+├─ Look for: multiple thin towers = function called many times
+└─ Ignore: narrow bars (insignificant time)
+
+Common findings:
+├─ Wide JSON.parse bar → large payload parsing
+├─ Wide sort bar → inefficient sorting algorithm or large dataset
+├─ Wide GC bar → too many allocations (reduce object creation)
+├─ Deep regex bar → regex backtracking (simplify pattern)
+└─ Wide I/O bar → blocking I/O on critical path
+```
+
+### Memory Profiling for Performance
+
+```bash
+# Go: allocation profiling
+go test -memprofile=mem.prof -bench=. ./pkg/...
+go tool pprof -alloc_objects mem.prof  # Count of allocations
+go tool pprof -alloc_space mem.prof    # Size of allocations
+
+# Node.js: allocation timeline in DevTools
+# Memory panel → Allocation instrumentation on timeline
+# Shows objects allocated over time, find what survives GC
+
+# Python: memray for allocation hot spots
+memray run --trace-python-allocators script.py
+memray flamegraph output.bin
+```
+
+### I/O Performance
+
+```bash
+# Identify slow queries
+# PostgreSQL:
+EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
+# Look for:
+#   Seq Scan on large table → add index
+#   Nested Loop with high row count → consider join strategy
+#   Sort with external merge → increase work_mem
+
+# N+1 query detection:
+# Count queries per request (log all queries, count):
+grep "SELECT\|INSERT\|UPDATE\|DELETE" query.log | wc -l
+# If count scales with data size → N+1 problem
+
+# Connection pool exhaustion:
+# PostgreSQL:
+SELECT count(*), state FROM pg_stat_activity GROUP BY state;
+# If active ≈ max_connections → pool exhaustion
+```
+
+## API Debugging
+
+### Request/Response Inspection
+
+```bash
+# Full request/response with timing
+curl -v -w "\n\nTiming:\n  DNS:     %{time_namelookup}s\n  Connect: %{time_connect}s\n  TLS:     %{time_appconnect}s\n  TTFB:    %{time_starttransfer}s\n  Total:   %{time_total}s\n  Size:    %{size_download} bytes\n" \
+  -H "Authorization: Bearer $TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{"key": "value"}' \
+  https://api.example.com/endpoint
+
+# Compare expected vs actual response
+diff <(curl -s expected-endpoint | jq .) <(curl -s actual-endpoint | jq .)
+```
+
+### Status Code Debugging
+
+```
+2xx: Success (but check response body for soft errors)
+├─ 200: OK
+├─ 201: Created (check Location header for new resource URL)
+└─ 204: No Content (no response body expected)
+
+3xx: Redirect (follow with curl -L, check redirect chain)
+├─ 301: Permanent redirect (cache implications)
+├─ 302: Temporary redirect
+└─ 304: Not Modified (caching working correctly)
+
+4xx: Client error (fix the request)
+├─ 400: Bad Request → check request body against API schema
+├─ 401: Unauthorized → check token validity, expiration
+├─ 403: Forbidden → check permissions, scopes, IP allowlist
+├─ 404: Not Found → check URL path, resource existence
+├─ 405: Method Not Allowed → check HTTP method (GET vs POST)
+├─ 409: Conflict → check for duplicate/concurrent operations
+├─ 413: Payload Too Large → reduce request body size
+├─ 422: Unprocessable → valid JSON but semantic errors
+├─ 429: Rate Limited → check Retry-After header, implement backoff
+└─ 431: Headers Too Large → reduce cookie/header size
+
+5xx: Server error (usually not your fault, but check your request)
+├─ 500: Internal Server Error → check server logs
+├─ 502: Bad Gateway → upstream service down
+├─ 503: Service Unavailable → service overloaded or deploying
+└─ 504: Gateway Timeout → upstream too slow, check timeout settings
+```
+
+### Header Debugging
+
+```bash
+# CORS debugging
+curl -v -X OPTIONS \
+  -H "Origin: http://localhost:3000" \
+  -H "Access-Control-Request-Method: POST" \
+  -H "Access-Control-Request-Headers: Content-Type,Authorization" \
+  https://api.example.com/endpoint
+
+# Check response headers:
+# Access-Control-Allow-Origin: must match your origin (or *)
+# Access-Control-Allow-Methods: must include your method
+# Access-Control-Allow-Headers: must include your custom headers
+# Access-Control-Allow-Credentials: must be true if sending cookies
+
+# Content-Type debugging
+# Sending JSON but getting 400? Check:
+curl -H "Content-Type: application/json" ...   # CORRECT
+curl -H "Content-Type: text/plain" ...         # WRONG for JSON APIs
+
+# Auth header debugging
+# Bearer token:
+curl -H "Authorization: Bearer eyJhbG..." ...
+# Basic auth:
+curl -u username:password ...
+# API key:
+curl -H "X-API-Key: your-key" ...
+```
+
+### Payload Debugging
+
+```bash
+# Validate JSON syntax
+echo '{"key": "value"}' | jq .
+
+# Pretty-print API response
+curl -s https://api.example.com/endpoint | jq .
+
+# Compare schemas
+# Save expected schema and actual response, then diff
+curl -s https://api.example.com/endpoint | jq 'keys' > actual-keys.json
+diff expected-keys.json actual-keys.json
+
+# Check encoding issues
+curl -s https://api.example.com/endpoint | file -
+# Should show: "UTF-8 Unicode text" or "ASCII text"
+# If "ISO-8859" or "binary" → encoding mismatch
+
+# Large payload debugging
+curl -s https://api.example.com/endpoint | jq '. | length'  # Array length
+curl -s https://api.example.com/endpoint | wc -c             # Byte count
+```
+
+### Timeout and Retry Debugging
+
+```bash
+# Test with explicit timeout
+curl --connect-timeout 5 --max-time 30 https://api.example.com/endpoint
+
+# If timing out, check at each layer:
+# 1. DNS resolution
+dig api.example.com
+nslookup api.example.com
+
+# 2. TCP connectivity
+nc -zv api.example.com 443
+
+# 3. TLS handshake
+openssl s_client -connect api.example.com:443
+
+# 4. HTTP response time
+curl -o /dev/null -s -w "TTFB: %{time_starttransfer}s\n" https://api.example.com/endpoint
+
+# Retry with exponential backoff (script)
+for i in 1 2 4 8 16; do
+    if curl -sf https://api.example.com/health; then
+        echo "Service is up"
+        break
+    fi
+    echo "Retry in ${i}s..."
+    sleep $i
+done
+```
+
+## Deployment Issues ("Works on My Machine")
+
+### Environment Diff Checklist
+
+```bash
+# 1. OS and architecture
+uname -a                              # Linux/macOS
+# Compare: local vs CI vs production
+
+# 2. Runtime versions
+node --version                        # Node.js
+python --version                      # Python
+go version                            # Go
+rustc --version                       # Rust
+
+# 3. Dependency versions
+# Node.js:
+diff <(cat package-lock.json | jq '.dependencies | keys') \
+     <(ssh prod 'cat /app/package-lock.json | jq ".dependencies | keys"')
+
+# Python:
+diff <(pip list --format=freeze | sort) \
+     <(ssh prod 'pip list --format=freeze | sort')
+
+# 4. Environment variables
+diff <(env | sort | grep -v SECRET) \
+     <(ssh prod 'env | sort | grep -v SECRET')
+
+# 5. Config files (byte-for-byte comparison)
+diff local.env <(ssh prod 'cat /app/.env')
+
+# 6. System resources
+free -h                               # Memory
+df -h                                 # Disk space
+ulimit -n                             # File descriptor limit
+```
+
+### Docker Reproducibility
+
+```bash
+# Ensure same image locally and in production
+docker inspect IMAGE --format '{{.Id}}'  # Compare image IDs
+
+# Run locally with production-equivalent constraints
+docker run \
+  --memory=512m \
+  --cpus=1 \
+  --env-file production.env \
+  --network=host \
+  IMAGE
+
+# Debug inside the exact production image
+docker run -it --entrypoint /bin/sh PRODUCTION_IMAGE
+```
+
+### Dependency Differences
+
+```bash
+# Check if lock file is fresh
+# Node.js: compare node_modules to lock file
+npm ls --all 2>&1 | grep "WARN\|ERR"
+
+# Python: check for mismatched requirements
+pip check
+
+# Go: verify module checksum
+go mod verify
+
+# Common issue: "works locally" because you have a package
+# installed globally that is not in the project's dependencies
+# Test: run in clean environment (Docker, CI)
+```
+
+### File System Differences
+
+```
+Common traps:
+├─ Case sensitivity: macOS/Windows are case-insensitive, Linux is case-sensitive
+│  import User from './user'  ← works on Mac, fails on Linux if file is User.js
+│
+├─ Path separators: Windows uses \, Linux/macOS uses /
+│  Use path.join() or path.resolve(), never hardcode separators
+│
+├─ Line endings: Windows CRLF (\r\n) vs Unix LF (\n)
+│  Scripts with CRLF fail on Linux: /bin/bash^M: bad interpreter
+│  Fix: git config core.autocrlf input
+│
+├─ File permissions: Linux/macOS have execute bits, Windows does not
+│  chmod +x script.sh has no effect on Windows
+│
+├─ Max path length: Windows has 260 char limit (unless LongPathsEnabled)
+│  node_modules paths can exceed this on Windows
+│
+└─ Symlinks: Windows requires admin privileges or Developer Mode
+   npm link / yarn link may fail on Windows
+```
+
+### Network Differences
+
+```bash
+# DNS resolution differences
+dig +short api.example.com              # What does DNS resolve to here?
+ssh prod 'dig +short api.example.com'   # What about in production?
+
+# Firewall differences
+# Can the production server reach the external API?
+ssh prod 'curl -sv https://external-api.com/health 2>&1 | head -20'
+
+# Proxy differences
+echo $HTTP_PROXY $HTTPS_PROXY $NO_PROXY
+ssh prod 'echo $HTTP_PROXY $HTTPS_PROXY $NO_PROXY'
+
+# TLS/certificate differences
+openssl s_client -connect api.example.com:443 < /dev/null 2>/dev/null | openssl x509 -noout -dates
+# Check if production has different CA bundle
+ssh prod 'openssl s_client -connect api.example.com:443 < /dev/null 2>/dev/null | openssl x509 -noout -dates'
+
+# MTU / packet size issues (rare but painful)
+ping -M do -s 1472 api.example.com      # Test path MTU
+```
+
+### Quick "Works on My Machine" Decision Tree
+
+```
+Does it fail in Docker locally (same image as prod)?
+├─ No → Environment difference. Compare: env vars, config, DNS, network
+└─ Yes → Does it fail in CI?
+   ├─ No → Data or state difference. Compare: database, cache, file system
+   └─ Yes → Code bug. Use standard debugging workflow.
+       └─ But I swear it works on my machine!
+          → Run in clean checkout: git stash && npm ci && npm test
+          → If it passes: your working tree has uncommitted changes that fix it
+          → If it fails: local cache/build artifact masking the bug
+             → rm -rf node_modules .next dist build && npm ci && npm test
+```

+ 591 - 0
skills/debug-ops/references/systematic-methods.md

@@ -0,0 +1,591 @@
+# Systematic Debugging Methods
+
+Structured approaches to finding and fixing bugs, from scientific method to team-based protocols.
+
+## Scientific Debugging Method
+
+The most rigorous approach: treat debugging as an experiment.
+
+### The Cycle
+
+```
+Observe → Hypothesize → Predict → Test → Conclude
+   ↑                                        │
+   └────────────────────────────────────────┘
+           (if hypothesis rejected)
+```
+
+### Step-by-Step
+
+1. **Observe**: Gather all available evidence without interpretation
+   - Error messages, stack traces, logs
+   - System state (memory, CPU, disk, network)
+   - User-reported behavior vs expected behavior
+   - When it started (correlate with deployments, config changes)
+
+2. **Hypothesize**: Form a specific, falsifiable explanation
+   - "The query times out because the users table lacks an index on email"
+   - NOT "something is wrong with the database" (too vague)
+
+3. **Predict**: State what should happen if the hypothesis is true
+   - "If I add an index on email, the query should complete in <100ms"
+   - "If I run EXPLAIN on the query, it should show a sequential scan"
+
+4. **Test**: Run the smallest experiment that distinguishes true from false
+   - Run EXPLAIN ANALYZE on the query
+   - Check if the index exists: `\di users_email_idx`
+
+5. **Conclude**: Accept or reject the hypothesis based on evidence
+   - If confirmed: proceed to fix
+   - If rejected: return to step 1 with new information
+
+### Example Walkthrough
+
+```
+OBSERVATION:
+  API endpoint POST /api/orders returns 500 after deploying v2.3.1
+  Error log: "TypeError: Cannot read property 'id' of undefined"
+  Stack trace points to orders.controller.js:47
+
+HYPOTHESIS 1:
+  "The user object is null because the auth middleware is not
+   attaching it to the request in the new version"
+
+PREDICTION:
+  "If I log req.user in the auth middleware, it will be undefined
+   for the failing requests"
+
+TEST:
+  Added console.log(req.user) in auth middleware
+  Result: req.user IS defined and correct
+
+CONCLUSION:
+  Hypothesis 1 REJECTED. The user object exists.
+
+HYPOTHESIS 2:
+  "The order.customer field changed from an object to a string ID
+   in the new schema, so order.customer.id fails"
+
+PREDICTION:
+  "If I inspect the order document, customer will be a string, not
+   an object with an .id property"
+
+TEST:
+  db.orders.findOne({_id: "failing-order-id"})
+  Result: { customer: "user_123", ... }  -- string, not object
+
+CONCLUSION:
+  Hypothesis 2 CONFIRMED. Schema migration changed customer from
+  embedded object to reference ID. Fix: update controller to handle
+  both formats or ensure migration is complete.
+```
+
+## Binary Search Debugging
+
+Divide the search space in half with each step. Works for both code and history.
+
+### Git Bisect (Automated)
+
+Find the exact commit that introduced a bug:
+
+```bash
+# Start bisect
+git bisect start
+
+# Mark current (broken) state as bad
+git bisect bad
+
+# Mark a known-good commit
+git bisect good v2.2.0
+
+# Automate with a test script (exit 0 = good, exit 1 = bad)
+git bisect run ./test-bug.sh
+```
+
+Example test script:
+
+```bash
+#!/bin/bash
+# test-bug.sh - exits 0 if bug is absent, 1 if present
+
+# Build the project (skip if not needed)
+npm install --silent 2>/dev/null
+npm run build --silent 2>/dev/null
+
+# Run the specific test that catches the bug
+npm test -- --grep "order creation" 2>/dev/null
+exit $?
+```
+
+```bash
+# After bisect completes:
+# "abc1234 is the first bad commit"
+
+# View the offending commit
+git show abc1234
+
+# Clean up
+git bisect reset
+```
+
+### Git Bisect (Manual)
+
+```bash
+git bisect start
+git bisect bad HEAD
+git bisect good v2.2.0
+
+# Git checks out a middle commit
+# Test manually, then mark:
+git bisect good   # if this commit works
+git bisect bad    # if this commit is broken
+
+# Repeat until the first bad commit is found
+# ~10 steps for 1000 commits (log2(1000) ≈ 10)
+```
+
+### Manual Code Bisection
+
+When the bug is in a single file or function:
+
+```
+1. Comment out the bottom half of the suspect function
+2. Test → still broken? Bug is in the top half
+3. Uncomment bottom half, comment out top half of remaining suspect code
+4. Repeat until you isolate the exact line(s)
+```
+
+This is particularly effective for:
+- Long functions with no clear fault location
+- Template/config files where errors are positional
+- CSS debugging (comment out rule blocks)
+
+## Wolf Fence Algorithm
+
+Named after the strategy of placing a fence across a territory to determine which side the wolf is on.
+
+### Concept
+
+Place a "probe" (assertion, log, breakpoint) at the midpoint of execution. Check if the state is correct at that point. If correct, the bug is downstream. If incorrect, it is upstream. Repeat.
+
+### Implementation
+
+```python
+def process_order(order):
+    validated = validate(order)
+    # PROBE 1: is validated correct here?
+    assert validated.total > 0, f"Probe 1 failed: total={validated.total}"
+
+    enriched = enrich_with_inventory(validated)
+    # PROBE 2: is enriched correct here?
+    assert enriched.items_available, f"Probe 2 failed: items={enriched.items}"
+
+    charged = charge_payment(enriched)
+    # PROBE 3: is charged correct here?
+    assert charged.payment_id, f"Probe 3 failed: payment={charged.payment_id}"
+
+    return finalize(charged)
+```
+
+### Strategic Probe Placement
+
+```
+Place probes at:
+├─ Function entry/exit boundaries
+├─ Before/after external calls (DB, API, filesystem)
+├─ Before/after data transformations
+├─ At conditional branches (which path was taken?)
+└─ At loop boundaries (iteration count, accumulator value)
+```
+
+## Rubber Duck Debugging
+
+Explaining the problem forces you to examine your assumptions.
+
+### Structured Rubber Duck Template
+
+```
+1. WHAT I EXPECT TO HAPPEN:
+   [describe the correct behavior in detail]
+
+2. WHAT ACTUALLY HAPPENS:
+   [describe the observed behavior precisely]
+
+3. THE GAP:
+   [what is different between expected and actual?]
+
+4. MY CODE DOES THIS:
+   [walk through the relevant code line by line]
+   - Line 1: "First, we fetch the user by ID..."
+   - Line 2: "Then we check if the user has permission..."
+   - Line 3: "Wait... we check user.role but role could be
+              undefined if the user was created before we
+              added roles... THAT'S THE BUG"
+
+5. ASSUMPTIONS I AM MAKING:
+   [list every assumption, then question each one]
+   - "The user always has a role" ← IS THIS TRUE?
+   - "The database returns results in insertion order" ← IS THIS TRUE?
+   - "The config file is loaded before this function runs" ← IS THIS TRUE?
+```
+
+### Why It Works
+
+- Forces sequential reasoning instead of pattern-matching
+- Exposes implicit assumptions
+- Catches "it obviously works" blind spots
+- The bug is usually found in step 4 or 5
+
+## Differential Debugging
+
+Compare what works against what does not.
+
+### Method
+
+```
+Working State          vs.          Broken State
+─────────────                      ────────────
+Environment A                      Environment B
+Input set X                        Input set Y
+Version N-1                        Version N
+Config A                           Config B
+```
+
+### Practical Comparison Commands
+
+```bash
+# Compare environment variables
+diff <(env | sort) <(ssh prod 'env | sort')
+
+# Compare installed packages
+diff <(pip list --format=freeze | sort) <(ssh prod 'pip list --format=freeze | sort')
+
+# Compare config files
+diff local.env production.env
+difft config-working.yaml config-broken.yaml  # semantic diff
+
+# Compare database schemas
+diff <(pg_dump --schema-only dbA) <(pg_dump --schema-only dbB)
+
+# Compare API responses
+diff <(curl -s localhost:3000/api/users | jq .) <(curl -s prod:3000/api/users | jq .)
+
+# Compare directory structures
+diff <(fd -t f . ./working/ | sort) <(fd -t f . ./broken/ | sort)
+```
+
+### Environment Diff Checklist
+
+```
+[ ] OS and version
+[ ] Runtime version (node --version, python --version, go version)
+[ ] Dependency versions (package-lock.json, requirements.txt, go.sum)
+[ ] Environment variables (especially secrets, API keys, feature flags)
+[ ] Config files (compare byte-for-byte)
+[ ] Database schema and seed data
+[ ] Network configuration (firewall, DNS, proxy)
+[ ] File system (permissions, case sensitivity, available disk)
+[ ] System resources (memory, CPU, file descriptors)
+[ ] Time and timezone
+```
+
+## Delta Debugging
+
+Systematically reduce the input or code to find the minimal failing case.
+
+### Concept (ddmin Algorithm)
+
+```
+Given: A failing input of N elements
+Goal:  Find the smallest subset that still triggers the failure
+
+1. Split input into 2 halves
+2. Test each half:
+   - If one half fails alone → recurse on that half
+   - If neither half fails alone → the bug requires elements from both
+     → try removing each quarter, then each eighth, etc.
+3. Stop when no single element can be removed without fixing the bug
+```
+
+### Practical Application
+
+```bash
+# Reduce a failing test input file
+# Start: 1000-line input.json that causes a crash
+
+# Test: does the first half crash?
+head -500 input.json > test.json && ./program test.json
+
+# If yes: recurse on first 500 lines
+# If no: test second half
+tail -500 input.json > test.json && ./program test.json
+
+# Continue halving until minimal input found
+```
+
+### Code Reduction
+
+```
+1. Start with the full failing program
+2. Remove half the code (e.g., half the imports, half the functions)
+3. Does it still fail?
+   - Yes → keep removing from what remains
+   - No → restore that half, remove the other half
+4. Result: minimal code that reproduces the failure
+```
+
+### Tools
+
+- **C-Reduce** (`creduce`): Automated C/C++ test case reduction
+- **Perses**: Language-agnostic program reducer
+- **picireny**: Python-based delta debugging framework
+- Manual reduction is often fastest for small programs
+
+## Trace-Based Debugging
+
+Follow the execution path through the system.
+
+### Strategic Trace Points
+
+```python
+import functools
+import time
+import json
+
+def trace(func):
+    """Decorator that traces function entry, exit, and timing."""
+    @functools.wraps(func)
+    def wrapper(*args, **kwargs):
+        call_id = id(args) % 10000
+        arg_summary = json.dumps(args[:3], default=str)[:100]
+        print(f"[TRACE {call_id}] ENTER {func.__name__}({arg_summary})")
+        start = time.perf_counter()
+        try:
+            result = func(*args, **kwargs)
+            elapsed = (time.perf_counter() - start) * 1000
+            result_summary = str(result)[:100]
+            print(f"[TRACE {call_id}] EXIT  {func.__name__} -> {result_summary} ({elapsed:.1f}ms)")
+            return result
+        except Exception as e:
+            elapsed = (time.perf_counter() - start) * 1000
+            print(f"[TRACE {call_id}] ERROR {func.__name__}: {e} ({elapsed:.1f}ms)")
+            raise
+    return wrapper
+```
+
+### System Call Tracing
+
+```bash
+# Linux: trace system calls
+strace -f -e trace=network,file -p PID
+
+# Trace a command from start
+strace -f -o trace.log ./my-program
+
+# Count system calls (find the hot path)
+strace -c ./my-program
+
+# macOS: dtrace/dtruss
+sudo dtruss -p PID
+
+# Trace file access only
+strace -e trace=open,openat,read,write -p PID
+```
+
+### Structured Trace Output
+
+```
+Timestamp   | Component     | Event    | Detail
+------------|---------------|----------|---------------------------
+10:23:01.001| auth-service  | ENTRY    | validateToken(tok_abc...)
+10:23:01.003| auth-service  | CALL     | redis.get("session:abc")
+10:23:01.015| auth-service  | RETURN   | redis -> {user: "u123"}
+10:23:01.016| auth-service  | EXIT     | validateToken -> valid
+10:23:01.017| order-service | ENTRY    | createOrder(user=u123)
+10:23:01.018| order-service | CALL     | db.query("SELECT ...")
+10:23:01.250| order-service | RETURN   | db -> timeout after 232ms  ← PROBLEM
+```
+
+## Time-Travel Debugging
+
+Record execution and replay it, stepping forwards AND backwards.
+
+### rr (Linux only)
+
+```bash
+# Record the execution
+rr record ./my-program arg1 arg2
+
+# Replay (starts at the end, you can go backwards)
+rr replay
+
+# Inside the rr session (gdb-like interface):
+(rr) continue           # run forward
+(rr) reverse-continue   # run backward to previous breakpoint
+(rr) reverse-next       # step backward one line
+(rr) reverse-step       # step backward into function calls
+(rr) watch -l var       # break when var changes (works in reverse too)
+
+# Set a breakpoint and reverse-continue to find what set a value
+(rr) break my_function
+(rr) continue           # hit the breakpoint going forward
+(rr) watch -l result    # watch the variable
+(rr) reverse-continue   # go back to where result was last set
+```
+
+### When to Use Time-Travel Debugging
+
+- Bug manifests late but root cause is early in execution
+- You need to find "what set this variable to the wrong value?"
+- Intermittent bugs that are hard to reproduce (record once, replay many times)
+- Complex multi-step state corruption
+
+### Alternatives by Language
+
+| Language | Tool | Notes |
+|----------|------|-------|
+| C/C++ | rr | Best-in-class, Linux only |
+| C/C++ | UDB (UndoDB) | Commercial, cross-platform |
+| JavaScript | Chrome DevTools | "Step backward" in Sources panel (limited) |
+| Python | `epdb` / `pdb++` | Post-mortem with history, not true time-travel |
+| Java | IntelliJ IDEA | Limited reverse debugging |
+| .NET | Visual Studio | IntelliTrace (Enterprise edition) |
+
+## Hypothesis-Driven Debugging
+
+Maintain a structured log of hypotheses to avoid going in circles.
+
+### Tracking Template
+
+```markdown
+## Bug: [Brief description]
+
+### Evidence Collected
+- [ ] Error message: "..."
+- [ ] Stack trace captured
+- [ ] Logs reviewed for timeframe: X to Y
+- [ ] Reproduction rate: N/M attempts
+
+### Hypotheses
+
+| # | Hypothesis | Prediction | Test | Result | Status |
+|---|-----------|------------|------|--------|--------|
+| 1 | Missing index on users.email | EXPLAIN shows seq scan | Run EXPLAIN ANALYZE | Shows index scan | REJECTED |
+| 2 | Connection pool exhausted | Active connections = max | Check pg_stat_activity | 47/50 connections | INVESTIGATING |
+| 3 | | | | | |
+
+### Current Best Hypothesis: #2
+
+### What I Tried That Didn't Work
+- Restarting the service (symptom returned after 5 min)
+- Increasing query timeout (different error, same root cause)
+```
+
+### Rules
+
+1. Write hypotheses down before testing them
+2. Define the prediction before running the test
+3. Record results even for rejected hypotheses (prevents retesting)
+4. If you have tested 5+ hypotheses without progress, step back and re-examine assumptions
+
+## Debugging Checklists
+
+### Pre-Debug Checklist
+
+```
+[ ] Read the full error message and stack trace
+[ ] Check if this is a known issue (search issue tracker, logs)
+[ ] Identify when it started (correlate with recent changes)
+[ ] Verify you are on the correct branch/version
+[ ] Check recent commits: git log --oneline -10
+[ ] Check recent deployments or config changes
+[ ] Reproduce the bug locally
+[ ] Set a time limit (30 min before seeking help)
+```
+
+### During-Debug Log
+
+```
+Time    | Action Taken              | Result           | Next Step
+--------|---------------------------|------------------|----------
+10:00   | Reproduced locally        | Fails 3/3 times  | Check logs
+10:05   | Checked error logs        | Found stack trace | Form hypothesis
+10:10   | Hypothesis: null user obj | Test: add assert  | Test
+10:15   | Assert passed (user OK)   | Rejected H1      | New hypothesis
+10:20   | Hypothesis: stale cache   | Test: clear cache | Test
+10:22   | Cache cleared, bug gone   | Confirmed H2     | Find root cause
+10:30   | Cache TTL was 0 (never expires) | Root cause found | Fix
+```
+
+### Post-Debug Retrospective
+
+```
+[ ] Root cause documented
+[ ] Fix applied and verified
+[ ] Regression test added
+[ ] Could this bug class exist elsewhere? (search for similar patterns)
+[ ] Was the debugging process efficient? What would I do differently?
+[ ] Knowledge shared with team (if applicable)
+[ ] Monitoring/alerting added to catch recurrence
+```
+
+## When to Stop Debugging
+
+### Diminishing Returns Signals
+
+- You have been debugging for 2+ hours without new information
+- You are retesting hypotheses you already rejected
+- You are making changes "just to see what happens"
+- You are frustrated and making mistakes
+
+### Decision Framework
+
+```
+Can you reproduce it?
+├─ No → Workaround + monitoring + move on
+└─ Yes
+   ├─ Is the impact high? (data loss, security, outage)
+   │  └─ Yes → Keep debugging, escalate if needed
+   └─ Is the impact low? (cosmetic, edge case, rare)
+      └─ Yes → Workaround + backlog ticket + move on
+```
+
+### Escalation Criteria
+
+- You have spent 2x your initial time estimate
+- You need access to systems/data you do not have
+- The bug crosses team boundaries (your service + another team's service)
+- You suspect a bug in a third-party library or runtime
+
+## Debugging in Teams
+
+### Pair Debugging
+
+Two people, one screen. One drives (types), one navigates (thinks strategically).
+
+- Driver focuses on tactical execution
+- Navigator watches for wrong turns, suggests hypotheses
+- Switch roles every 20-30 minutes
+- Navigator should resist the urge to grab the keyboard
+
+### Fresh Eyes Protocol
+
+When stuck, explain the problem to a colleague who has NOT been debugging it:
+
+1. Describe the expected behavior
+2. Describe the actual behavior
+3. List hypotheses tested and their results
+4. Ask: "What am I missing?"
+
+The fresh person often spots an assumption the original debugger has gone blind to.
+
+### Knowledge Transfer After Debugging
+
+```
+Share with the team:
+├─ What the bug was (root cause, not just symptom)
+├─ How it was found (which technique worked)
+├─ Why existing tests/monitoring did not catch it
+├─ What was added to prevent recurrence
+└─ Any broader lessons (design patterns, common pitfalls)
+```

+ 934 - 0
skills/debug-ops/references/tool-specific.md

@@ -0,0 +1,934 @@
+# Tool-Specific Debugging Reference
+
+Detailed guides for debugging tools across languages and environments.
+
+## Browser DevTools
+
+### Elements Panel
+
+```
+Inspect and modify the DOM and CSS in real-time.
+
+Key Operations:
+├─ Right-click element → Inspect (jump to element in DOM tree)
+├─ Ctrl+Shift+C: Pick element mode (click any element to inspect)
+├─ Edit HTML: Double-click tag name, attribute, or text content
+├─ Force element state: Right-click → Force state → :hover, :active, :focus
+├─ Break on DOM changes: Right-click element → Break on → subtree/attribute/removal
+└─ Computed tab: See final computed CSS values and which rule wins
+```
+
+CSS debugging workflow:
+
+```
+1. Inspect the element
+2. Check Computed tab for the actual value
+3. Look for strikethrough rules (overridden)
+4. Check for specificity conflicts
+5. Toggle rules on/off with checkboxes
+6. Use the color picker, shadow editor, easing editor for visual tweaking
+7. Changes persist until page reload (or use Overrides for persistence)
+```
+
+Event listener debugging:
+
+```
+Elements panel → Event Listeners tab
+├─ Shows all listeners attached to selected element
+├─ Check "Ancestors" to see inherited/delegated listeners
+├─ Click filename link to jump to handler source code
+├─ "Remove" button to detach a listener for testing
+└─ Framework listeners: check "Framework listeners" to unwrap React/Vue
+```
+
+Accessibility inspection:
+
+```
+Elements panel → Accessibility tab
+├─ ARIA role and computed properties
+├─ Accessible name and description
+├─ Keyboard focusability
+└─ Color contrast ratio
+```
+
+### Console Panel
+
+Essential console methods beyond `console.log`:
+
+```javascript
+// Structured data display
+console.table(arrayOfObjects);               // Tabular format with sorting
+console.table(arrayOfObjects, ['name','id']); // Show only specific columns
+
+// Grouping related logs
+console.group('Processing order #123');       // Collapsible group (open)
+console.groupCollapsed('Details');            // Collapsible group (closed)
+console.log('item count:', items.length);
+console.groupEnd();
+console.groupEnd();
+
+// Timing operations
+console.time('fetchUsers');
+await fetchUsers();
+console.timeEnd('fetchUsers');               // "fetchUsers: 142.3ms"
+
+// Stack traces
+console.trace('How did we get here?');       // Prints call stack
+
+// Assertions (only logs on failure)
+console.assert(user.age > 0, 'Invalid age:', user.age);
+
+// Counting occurrences
+console.count('render');                     // "render: 1", "render: 2", etc.
+console.countReset('render');
+
+// Styled output
+console.log('%cIMPORTANT', 'color: red; font-size: 20px; font-weight: bold');
+
+// Object with label
+console.dir(domElement, { depth: 3 });       // Expandable object tree
+```
+
+Console utilities (only available in DevTools console, not in code):
+
+```javascript
+$0                    // Currently selected element in Elements panel
+$_                    // Result of last expression
+$('selector')         // document.querySelector shorthand
+$$('selector')        // document.querySelectorAll as array
+$x('//xpath')         // XPath query
+
+copy(object)          // Copy object as JSON to clipboard
+clear()               // Clear the console
+
+// Monitor function calls
+monitor(functionName) // Log every call with arguments
+unmonitor(functionName)
+
+// Monitor events on an element
+monitorEvents($0, 'click')   // Log all click events on selected element
+monitorEvents(window, 'resize')
+unmonitorEvents($0, 'click')
+
+// Get event listeners for an element
+getEventListeners($0)
+
+// Query objects by constructor
+queryObjects(Promise)         // Find all Promise instances in heap
+```
+
+### Network Panel
+
+```
+Key Features:
+├─ Filter by type: XHR, JS, CSS, Img, Media, Font, Doc, WS, Manifest
+├─ Filter by text: URL contains, status code, method
+├─ Throttling: Simulate slow connections (Slow 3G, Offline, Custom)
+├─ Request blocking: Right-click → Block request URL/domain
+├─ Replay request: Right-click → Replay XHR (re-sends identical request)
+├─ Copy: Right-click → Copy as cURL/fetch/Node.js
+└─ HAR export: Right-click → Save all as HAR with content
+```
+
+Debugging API calls:
+
+```
+1. Open Network panel before reproducing the issue
+2. Filter by XHR/Fetch to see only API calls
+3. Click the failing request:
+   - Headers tab: check request method, URL, headers (Authorization, Content-Type)
+   - Payload tab: inspect request body
+   - Preview tab: formatted response
+   - Response tab: raw response
+   - Timing tab: DNS lookup, TLS, TTFB, content download breakdown
+4. Check for CORS errors:
+   - Look for preflight OPTIONS request
+   - Check Access-Control-Allow-Origin in response
+   - Console will show CORS error message
+5. Waterfall: hover over bars to see timing breakdown
+```
+
+### Performance Panel
+
+```bash
+# Recording a performance trace:
+1. Open Performance panel
+2. Click Record (or Ctrl+E)
+3. Perform the action to profile
+4. Click Stop
+5. Analyze the flame chart
+
+# Key sections:
+├─ Summary: pie chart of time spent (Scripting, Rendering, Painting, Idle)
+├─ Main: flame chart showing call stack over time
+│  ├─ Wide bars = long-running functions
+│  ├─ Red corner = long task (>50ms blocks main thread)
+│  └─ Click any bar to see source location and timing
+├─ Network: requests timeline correlated with execution
+├─ Frames: frame rate and dropped frames
+└─ Timings: user timing marks and measures
+```
+
+Web Vitals debugging:
+
+```javascript
+// Measure Core Web Vitals in code
+new PerformanceObserver((list) => {
+  for (const entry of list.getEntries()) {
+    console.log(`${entry.name}: ${entry.startTime.toFixed(0)}ms`);
+  }
+}).observe({ type: 'largest-contentful-paint', buffered: true });
+
+// Layout shift debugging
+new PerformanceObserver((list) => {
+  for (const entry of list.getEntries()) {
+    if (!entry.hadRecentInput) {
+      console.log('CLS:', entry.value, entry.sources);
+    }
+  }
+}).observe({ type: 'layout-shift', buffered: true });
+```
+
+### Memory Panel
+
+Heap snapshot workflow:
+
+```
+Three-Snapshot Technique (finding memory leaks):
+1. Take snapshot 1 (baseline)
+2. Perform the action suspected of leaking
+3. Take snapshot 2
+4. Perform the action again
+5. Take snapshot 3
+6. Select snapshot 3 → "Objects allocated between snapshot 1 and 2"
+7. Look for objects that should have been GC'd but weren't
+
+Key views:
+├─ Summary: objects grouped by constructor, sorted by retained size
+├─ Comparison: diff between two snapshots (delta of object counts)
+├─ Containment: object hierarchy from GC roots
+└─ Statistics: pie chart of memory by type
+```
+
+Common memory leak patterns:
+
+```javascript
+// Detached DOM nodes (common in SPAs)
+// Symptom: "Detached" elements in heap snapshot
+// Cause: JS reference to removed DOM node
+let cache = [];
+document.getElementById('btn').addEventListener('click', () => {
+  const el = document.createElement('div');
+  document.body.appendChild(el);
+  cache.push(el);  // ← Reference keeps element alive even after removal
+  document.body.removeChild(el);  // ← Element is "detached" but not GC'd
+});
+
+// Fix: remove from cache when element is removed, or use WeakRef
+```
+
+### Sources Panel
+
+Breakpoint types:
+
+```
+Line breakpoint:     Click line number gutter
+Conditional:         Right-click line → "Add conditional breakpoint"
+                     Expression: user.id === 'problem-user'
+Logpoint:            Right-click line → "Add logpoint"
+                     Expression: 'User:', user.name, 'at', new Date()
+DOM breakpoint:      Elements panel → right-click element → Break on
+XHR/fetch:           Sources → XHR/fetch Breakpoints → add URL substring
+Event listener:      Sources → Event Listener Breakpoints → check events
+                     (mouse, keyboard, timer, animation, etc.)
+Exception:           Sources → pause on caught/uncaught exceptions toggle
+```
+
+Source maps:
+
+```
+Enable source maps: Settings → Sources → Enable JavaScript/CSS source maps
+├─ Maps minified/transpiled code back to original source
+├─ Works with TypeScript, Babel, Webpack, etc.
+├─ Breakpoints set on original source, not compiled output
+└─ Stack traces show original file names and line numbers
+
+Debugging source map issues:
+1. Check for //# sourceMappingURL= comment at end of JS file
+2. Verify the .map file is accessible (Network panel, check 404)
+3. Check source map is valid: JSON.parse(mapContent)
+```
+
+## Node.js Debugging
+
+### Inspector Protocol
+
+```bash
+# Start with debugger (pauses at first line)
+node --inspect-brk app.js
+
+# Start with debugger (does not pause)
+node --inspect app.js
+
+# Custom port
+node --inspect=0.0.0.0:9230 app.js
+
+# Then open chrome://inspect in Chrome and click "inspect"
+```
+
+### ndb (Enhanced DevTools)
+
+```bash
+# Install globally
+npm install -g ndb
+
+# Debug a script
+ndb node app.js
+
+# Debug tests
+ndb npm test
+
+# Features over standard DevTools:
+# - Child process debugging (workers, clusters)
+# - Blackboxing of node_modules by default
+# - Edit and run code from within debugger
+# - Detect common async issues
+```
+
+### clinic.js Suite
+
+```bash
+# Install
+npm install -g clinic
+
+# Detect common performance issues
+clinic doctor -- node app.js
+# Generates HTML report with recommendations
+
+# Profile async operations (event loop delays)
+clinic bubbleprof -- node app.js
+# Shows async operation flow diagram
+
+# CPU flame graph
+clinic flame -- node app.js
+# Interactive flame chart in browser
+
+# Each tool outputs a .clinic/ directory with HTML report
+# Open the generated .html file in a browser
+```
+
+### 0x Flame Graphs
+
+```bash
+# Install
+npm install -g 0x
+
+# Generate flame graph
+0x app.js
+
+# With specific arguments
+0x -- node --max-old-space-size=4096 app.js
+
+# Output: flamegraph.html
+# Wider bars = more time spent in that function
+# Search for your code's function names to find hot paths
+```
+
+### Node.js Diagnostics
+
+```bash
+# Trace warnings with stack traces
+node --trace-warnings app.js
+
+# Trace deprecations
+node --trace-deprecation app.js
+
+# Enable debug logging for specific modules
+NODE_DEBUG=http,net node app.js
+NODE_DEBUG=stream,fs node app.js
+
+# Heap snapshot on OOM
+node --heapsnapshot-near-heap-limit=3 app.js
+
+# Generate diagnostic report
+node --report-on-fatalerror app.js
+# Or in code: process.report.writeReport()
+
+# Trace garbage collection
+node --trace-gc app.js
+```
+
+## Python Debugging
+
+### pdb Commands
+
+```
+# Launch pdb
+python -m pdb script.py
+
+# Or insert breakpoint in code (Python 3.7+)
+breakpoint()
+
+# Essential commands:
+n (next)          Step over (execute current line, stop at next)
+s (step)          Step into (enter function call)
+c (continue)      Run until next breakpoint
+r (return)        Run until current function returns
+
+p expr            Print expression value
+pp expr           Pretty-print expression
+l (list)          Show source around current line
+ll (longlist)     Show full source of current function
+w (where)         Print stack trace (alias: bt)
+u (up)            Move up one frame in stack
+d (down)          Move down one frame in stack
+
+b 42              Set breakpoint at line 42
+b module.py:42    Set breakpoint in specific file
+b func            Set breakpoint at function entry
+b 42, x > 10     Conditional breakpoint
+cl 1              Clear breakpoint number 1
+cl                Clear all breakpoints
+
+a (args)          Print arguments of current function
+display expr      Display expression value at each stop
+undisplay expr    Stop displaying
+
+commands 1        Set commands to run when breakpoint 1 is hit
+  p x
+  p y
+  c
+end
+
+interact          Start interactive Python shell with current scope
+```
+
+### debugpy (VS Code Remote Debugging)
+
+```python
+# In the application code
+import debugpy
+debugpy.listen(("0.0.0.0", 5678))
+print("Waiting for debugger to attach...")
+debugpy.wait_for_client()
+debugpy.breakpoint()
+```
+
+```json
+// VS Code launch.json
+{
+  "name": "Attach to Remote",
+  "type": "debugpy",
+  "request": "attach",
+  "connect": { "host": "localhost", "port": 5678 }
+}
+```
+
+### py-spy (Sampling Profiler)
+
+```bash
+# Install
+pip install py-spy
+
+# Profile a running process (no code changes needed)
+py-spy top --pid PID
+
+# Generate flame graph
+py-spy record -o profile.svg --pid PID
+
+# Profile a command
+py-spy record -o profile.svg -- python myapp.py
+
+# Dump all thread stacks (instant, non-invasive)
+py-spy dump --pid PID
+```
+
+### memray (Memory Profiler)
+
+```bash
+# Install
+pip install memray
+
+# Profile memory usage
+memray run script.py
+
+# Generate flame graph of allocations
+memray flamegraph output.bin -o memory.html
+
+# Show summary
+memray summary output.bin
+
+# Show top allocators
+memray stats output.bin
+
+# Live TUI during execution
+memray run --live script.py
+```
+
+### tracemalloc (Built-in Memory Tracking)
+
+```python
+import tracemalloc
+
+tracemalloc.start()
+
+# ... run suspect code ...
+
+snapshot = tracemalloc.take_snapshot()
+top_stats = snapshot.statistics('lineno')
+
+print("[ Top 10 memory allocations ]")
+for stat in top_stats[:10]:
+    print(stat)
+
+# Compare two snapshots to find leaks
+snapshot1 = tracemalloc.take_snapshot()
+# ... run more code ...
+snapshot2 = tracemalloc.take_snapshot()
+top_stats = snapshot2.compare_to(snapshot1, 'lineno')
+for stat in top_stats[:10]:
+    print(stat)
+```
+
+## Go Debugging
+
+### Delve (dlv)
+
+```bash
+# Debug a program
+dlv debug ./cmd/server
+
+# Debug a test
+dlv test ./pkg/handler
+
+# Attach to running process
+dlv attach PID
+
+# Connect remotely
+dlv debug --headless --listen=:2345 --api-version=2 ./cmd/server
+# Then in another terminal:
+dlv connect localhost:2345
+```
+
+### Delve Commands
+
+```
+# Breakpoints
+break main.go:42              Set breakpoint at file:line
+break mypackage.MyFunction    Set breakpoint at function
+cond 1 x > 100                Make breakpoint 1 conditional
+on 1 print x                  Execute command when breakpoint 1 hits
+clear 1                       Remove breakpoint 1
+clearall                      Remove all breakpoints
+
+# Execution
+continue (c)                  Run to next breakpoint
+next (n)                      Step over
+step (s)                      Step into
+stepout (so)                  Step out of current function
+restart (r)                   Restart program
+
+# Inspection
+print (p) variable            Print variable value
+display -a variable           Show variable at every stop
+set variable = value          Modify variable value
+locals                        Show all local variables
+args                          Show function arguments
+whatis variable               Show type of variable
+
+# Stack
+stack (bt)                    Print stack trace
+frame N                       Switch to frame N
+up                            Move up one frame
+down                          Move down one frame
+
+# Goroutines
+goroutines                    List all goroutines
+goroutine N                   Switch to goroutine N
+goroutines -t                 List goroutines with stack traces
+goroutine N bt                Stack trace for goroutine N
+```
+
+### Go Race Detector
+
+```bash
+# Run tests with race detector
+go test -race ./...
+
+# Run program with race detector
+go run -race ./cmd/server
+
+# Build with race detector
+go build -race -o server ./cmd/server
+
+# Example output:
+# ==================
+# WARNING: DATA RACE
+# Write at 0x00c0000b4010 by goroutine 7:
+#   main.increment()
+#       main.go:15 +0x38
+# Previous read at 0x00c0000b4010 by goroutine 6:
+#   main.printCount()
+#       main.go:20 +0x30
+# ==================
+```
+
+### Go pprof
+
+```go
+// Add to your application
+import _ "net/http/pprof"
+
+func main() {
+    go func() {
+        http.ListenAndServe("localhost:6060", nil)
+    }()
+    // ... rest of application
+}
+```
+
+```bash
+# CPU profile (30 seconds)
+go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
+
+# Heap profile
+go tool pprof http://localhost:6060/debug/pprof/heap
+
+# Goroutine profile (find leaks/deadlocks)
+go tool pprof http://localhost:6060/debug/pprof/goroutine
+
+# Block profile (find contention)
+go tool pprof http://localhost:6060/debug/pprof/block
+
+# Inside pprof interactive mode:
+(pprof) top              # Show top consumers
+(pprof) top -cum         # Top by cumulative time
+(pprof) list funcName    # Show annotated source
+(pprof) web              # Open flame graph in browser
+(pprof) svg > out.svg    # Export as SVG
+```
+
+## Rust Debugging
+
+### rust-gdb / rust-lldb
+
+```bash
+# Build with debug info (default for dev profile)
+cargo build
+
+# Debug with GDB (Linux)
+rust-gdb target/debug/myapp
+
+# Debug with LLDB (macOS)
+rust-lldb target/debug/myapp
+
+# Inside GDB:
+(gdb) break main           # Breakpoint at main
+(gdb) break src/lib.rs:42  # Breakpoint at file:line
+(gdb) run                  # Start program
+(gdb) next                 # Step over
+(gdb) step                 # Step into
+(gdb) print variable       # Print value (Rust-aware pretty printing)
+(gdb) bt                   # Backtrace
+(gdb) info threads          # List threads
+(gdb) thread 2             # Switch to thread 2
+```
+
+### Backtrace Control
+
+```bash
+# Short backtrace (usually sufficient)
+RUST_BACKTRACE=1 cargo run
+
+# Full backtrace (all frames including stdlib)
+RUST_BACKTRACE=full cargo run
+
+# Combine with log levels
+RUST_LOG=debug RUST_BACKTRACE=1 cargo run
+```
+
+### Miri (Undefined Behavior Detector)
+
+```bash
+# Install miri
+rustup component add miri
+
+# Run tests under miri (detects UB, use-after-free, data races)
+cargo miri test
+
+# Run a specific binary
+cargo miri run
+
+# Miri detects:
+# - Use of uninitialized memory
+# - Accessing memory out of bounds
+# - Use-after-free
+# - Invalid use of unsafe/raw pointers
+# - Data races (with -Zmiri-disable-isolation)
+# - Memory leaks (with -Zmiri-leak-check)
+```
+
+### cargo-flamegraph
+
+```bash
+# Install
+cargo install flamegraph
+
+# Generate flame graph (needs perf on Linux, dtrace on macOS)
+cargo flamegraph
+
+# For a specific binary
+cargo flamegraph --bin myapp
+
+# For tests
+cargo flamegraph --test my_test
+
+# Output: flamegraph.svg (open in browser)
+```
+
+## Database Debugging
+
+### PostgreSQL
+
+```sql
+-- Explain query execution plan
+EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'test@example.com';
+
+-- Verbose explain with all details
+EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
+SELECT * FROM orders WHERE user_id = 123 ORDER BY created_at DESC LIMIT 10;
+
+-- Key things to look for:
+-- Seq Scan (full table scan - usually bad on large tables)
+-- Nested Loop (can be slow with large datasets)
+-- Sort (in-memory vs disk sort)
+-- actual time vs estimated time (large difference = stale statistics)
+
+-- Check for slow queries (enable slow query log)
+ALTER SYSTEM SET log_min_duration_statement = 100; -- log queries > 100ms
+SELECT pg_reload_conf();
+
+-- View currently running queries
+SELECT pid, now() - pg_stat_activity.query_start AS duration,
+       query, state, wait_event_type, wait_event
+FROM pg_stat_activity
+WHERE state != 'idle'
+ORDER BY duration DESC;
+
+-- Kill a stuck query
+SELECT pg_cancel_backend(PID);      -- graceful
+SELECT pg_terminate_backend(PID);   -- force
+
+-- Check table statistics
+SELECT relname, n_live_tup, n_dead_tup,
+       last_vacuum, last_autovacuum, last_analyze
+FROM pg_stat_user_tables
+ORDER BY n_dead_tup DESC;
+
+-- Check index usage
+SELECT indexrelname, idx_scan, idx_tup_read, idx_tup_fetch
+FROM pg_stat_user_indexes
+ORDER BY idx_scan ASC;  -- low scan count = possibly unused index
+
+-- Lock analysis
+SELECT blocked_locks.pid AS blocked_pid,
+       blocked_activity.usename AS blocked_user,
+       blocking_locks.pid AS blocking_pid,
+       blocking_activity.usename AS blocking_user,
+       blocked_activity.query AS blocked_query,
+       blocking_activity.query AS blocking_query
+FROM pg_catalog.pg_locks blocked_locks
+JOIN pg_catalog.pg_stat_activity blocked_activity
+  ON blocked_activity.pid = blocked_locks.pid
+JOIN pg_catalog.pg_locks blocking_locks
+  ON blocking_locks.locktype = blocked_locks.locktype
+  AND blocking_locks.relation = blocked_locks.relation
+  AND blocking_locks.pid != blocked_locks.pid
+JOIN pg_catalog.pg_stat_activity blocking_activity
+  ON blocking_activity.pid = blocking_locks.pid
+WHERE NOT blocked_locks.granted;
+
+-- Connection pool debugging
+SELECT count(*), state FROM pg_stat_activity GROUP BY state;
+SHOW max_connections;
+```
+
+### pg_stat_statements
+
+```sql
+-- Enable (requires extension)
+CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
+
+-- Top queries by total time
+SELECT query, calls, total_exec_time, mean_exec_time,
+       rows, shared_blks_hit, shared_blks_read
+FROM pg_stat_statements
+ORDER BY total_exec_time DESC
+LIMIT 20;
+
+-- Top queries by calls (hot queries)
+SELECT query, calls, mean_exec_time
+FROM pg_stat_statements
+ORDER BY calls DESC
+LIMIT 20;
+
+-- Reset statistics
+SELECT pg_stat_statements_reset();
+```
+
+## Network Debugging
+
+### curl Verbose Mode
+
+```bash
+# Show full request/response headers and TLS handshake
+curl -v https://api.example.com/endpoint
+
+# Include timing breakdown
+curl -w "\n\nDNS: %{time_namelookup}s\nConnect: %{time_connect}s\nTLS: %{time_appconnect}s\nTTFB: %{time_starttransfer}s\nTotal: %{time_total}s\n" \
+  -o /dev/null -s https://api.example.com/endpoint
+
+# Follow redirects and show each hop
+curl -vL https://example.com/old-path
+
+# Send with specific headers
+curl -H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
+  -d '{"key":"value"}' https://api.example.com/endpoint
+
+# Save response headers
+curl -D headers.txt https://api.example.com/endpoint
+```
+
+### mitmproxy (HTTPS Interception)
+
+```bash
+# Start mitmproxy (TUI)
+mitmproxy -p 8080
+
+# Start as transparent proxy
+mitmproxy --mode transparent
+
+# Filter specific hosts
+mitmproxy --intercept "~d api.example.com"
+
+# Record and replay
+mitmdump -w traffic.flow        # Record
+mitmdump -r traffic.flow -p 8080 # Replay
+
+# Key bindings in mitmproxy TUI:
+# f: set filter expression
+# i: set intercept filter
+# e: edit intercepted request/response
+# r: replay request
+# z: clear all flows
+# ?: help
+```
+
+### tcpdump Quick Reference
+
+```bash
+# Capture HTTP traffic on port 80
+sudo tcpdump -i any port 80 -A
+
+# Capture and save to file
+sudo tcpdump -i any -w capture.pcap
+
+# Filter by host
+sudo tcpdump -i any host api.example.com
+
+# Filter by host and port
+sudo tcpdump -i any host 10.0.0.5 and port 5432
+
+# Read saved capture
+tcpdump -r capture.pcap
+
+# Show packet contents in hex and ASCII
+sudo tcpdump -XX -i any port 443
+```
+
+## Docker Debugging
+
+### Container Inspection
+
+```bash
+# View container logs
+docker logs CONTAINER --tail 100 -f        # Last 100 lines, follow
+docker logs CONTAINER --since 5m            # Last 5 minutes
+docker logs CONTAINER 2>&1 | rg "ERROR"    # Filter for errors
+
+# Execute command in running container
+docker exec -it CONTAINER /bin/sh           # Shell into container
+docker exec CONTAINER cat /etc/hosts        # Run single command
+docker exec CONTAINER env                    # Check environment
+
+# Inspect container configuration
+docker inspect CONTAINER                     # Full JSON config
+docker inspect --format '{{.State.Status}}' CONTAINER
+docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' CONTAINER
+
+# View resource usage
+docker stats CONTAINER                       # Live CPU, memory, I/O
+docker top CONTAINER                         # Running processes
+
+# Check health status
+docker inspect --format '{{.State.Health.Status}}' CONTAINER
+docker inspect --format '{{json .State.Health}}' CONTAINER | jq .
+
+# Copy files from container for inspection
+docker cp CONTAINER:/app/logs/error.log ./error.log
+
+# View filesystem changes
+docker diff CONTAINER                        # Shows A(added), C(changed), D(deleted)
+```
+
+### Debugging Crashed Containers
+
+```bash
+# Container exited? Check the exit code and logs
+docker ps -a --filter "status=exited"
+docker logs CONTAINER
+
+# Create image from stopped container for inspection
+docker commit CONTAINER debug-image
+docker run -it debug-image /bin/sh
+
+# Override entrypoint to debug startup issues
+docker run -it --entrypoint /bin/sh IMAGE
+
+# Check what the container was doing
+docker inspect CONTAINER --format '{{.State.Error}}'
+docker inspect CONTAINER --format '{{.State.OOMKilled}}'
+docker inspect CONTAINER --format '{{.State.ExitCode}}'
+# Exit codes: 0=success, 1=app error, 137=OOM/SIGKILL, 139=segfault, 143=SIGTERM
+```
+
+### Debugging Networking
+
+```bash
+# Check container networking
+docker network ls
+docker network inspect bridge
+docker exec CONTAINER ping other-container
+docker exec CONTAINER nslookup other-service
+docker exec CONTAINER curl -v http://other-service:8080/health
+
+# Debug DNS resolution
+docker exec CONTAINER cat /etc/resolv.conf
+
+# Check port mapping
+docker port CONTAINER
+```
+
+### strace in Container
+
+```bash
+# Run container with SYS_PTRACE capability
+docker run --cap-add SYS_PTRACE IMAGE
+
+# strace inside container
+docker exec CONTAINER strace -p 1 -f -e trace=network
+
+# nsenter from host (if strace not in container)
+PID=$(docker inspect --format '{{.State.Pid}}' CONTAINER)
+nsenter -t $PID -n tcpdump -i any -A
+```

+ 0 - 0
skills/debug-ops/scripts/.gitkeep


+ 281 - 0
skills/javascript-ops/SKILL.md

@@ -0,0 +1,281 @@
+---
+name: javascript-ops
+description: "JavaScript and Node.js patterns, async programming, modules, runtime internals, and modern ES2024+ features. Use for: javascript, js, node, nodejs, esm, commonjs, promise, async await, event loop, v8, npm, es6, es2024, worker threads, streams, event emitter, prototype, closure."
+allowed-tools: "Read Write Bash"
+related-skills: [typescript-ops, react-ops, vue-ops, testing-ops]
+---
+
+# JavaScript Operations
+
+Comprehensive reference for modern JavaScript and Node.js — async patterns, module systems, runtime internals, and ES2022-2025 features.
+
+---
+
+## Async Decision Tree
+
+```
+What are you doing asynchronously?
+│
+├─ Simple one-off operation (DB query, HTTP call)
+│   └─ async/await with try/catch  ✓ default choice
+│
+├─ Multiple independent operations
+│   ├─ All must succeed → Promise.all([a(), b(), c()])
+│   ├─ Don't care about failures → Promise.allSettled([...])
+│   └─ First one wins → Promise.race([...]) or Promise.any([...])
+│
+├─ Need external resolve/reject control (deferred)
+│   └─ Promise.withResolvers()  (ES2024)
+│
+├─ Processing a sequence of async values
+│   ├─ Known array → for...of with await inside loop
+│   └─ Unknown/infinite sequence → async generator + for await...of
+│
+├─ Large data / backpressure concerns
+│   └─ Streams (ReadableStream / node:stream)
+│       ├─ Transform data in flight → TransformStream / Transform
+│       └─ Pipe chain → stream.pipeline() (Node) / pipeThrough() (Web)
+│
+├─ CPU-intensive work (would block event loop)
+│   ├─ Short burst → offload with setTimeout(fn, 0) to yield
+│   └─ Real work → Worker (browser) / worker_threads (Node)
+│       └─ Shared memory needed → SharedArrayBuffer + Atomics
+│
+└─ Legacy code uses callbacks
+    └─ Wrap with util.promisify() (Node) or new Promise() constructor
+```
+
+---
+
+## Module System Decision Tree
+
+```
+Which module system should I use?
+│
+├─ New project / Node 18+
+│   └─ ESM  (set "type": "module" in package.json)
+│       ├─ import / export syntax
+│       ├─ Top-level await supported
+│       └─ Better tree-shaking with bundlers
+│
+├─ Publishing a library
+│   ├─ ESM-only → simplest, but breaks older CJS consumers
+│   ├─ CJS-only → safe but no tree-shaking
+│   └─ Dual package (recommended) → "exports" field with conditions
+│       ├─ "import": "./dist/index.mjs"
+│       └─ "require": "./dist/index.cjs"
+│
+├─ Existing CJS project, want ESM
+│   ├─ Per-file migration → rename to .mjs, update require → import
+│   ├─ Whole-project → add "type": "module", rename .cjs exceptions
+│   └─ Keep CJS, add ESM wrapper → create thin .mjs re-export layer
+│
+├─ Browser (no bundler)
+│   └─ Native ESM — <script type="module"> + importmap
+│
+└─ Need dynamic loading
+    └─ import() — works in both ESM and CJS files
+        ├─ Lazy routes / code splitting
+        └─ Conditional platform code
+```
+
+**Migration path:** CJS → Dual → ESM-only
+
+---
+
+## Event Loop Quick Reference
+
+```
+┌─────────────────────────────────────────────────────────┐
+│                    Call Stack                           │
+│   (synchronous code executes here)                      │
+└─────────────────────────┬───────────────────────────────┘
+                          │ stack empty?
+                          ▼
+┌─────────────────────────────────────────────────────────┐
+│              Microtask Queue  (drained fully)           │
+│   • Promise.then / .catch / .finally callbacks         │
+│   • queueMicrotask(fn)                                  │
+│   • MutationObserver callbacks (browser)                │
+│   • process.nextTick (Node — runs BEFORE other microtasks)│
+└─────────────────────────┬───────────────────────────────┘
+                          │ microtasks empty?
+                          ▼
+┌─────────────────────────────────────────────────────────┐
+│              Macrotask Queue  (one task per loop tick)  │
+│   • setTimeout / setInterval callbacks                  │
+│   • setImmediate (Node — runs in "check" phase)         │
+│   • I/O callbacks (network, file system)                │
+│   • requestAnimationFrame (browser)                     │
+│   • MessagePort / Worker messages                       │
+└─────────────────────────────────────────────────────────┘
+
+Node.js event loop PHASES (libuv):
+  timers → pending callbacks → idle/prepare → poll → check → close callbacks
+  └─ process.nextTick + microtasks drain after EVERY phase
+```
+
+**Key rules:**
+- Microtasks always run before the next macrotask
+- `process.nextTick` fires before other microtasks (Promise.then)
+- `setImmediate` fires in the "check" phase, after I/O callbacks
+- `setTimeout(fn, 0)` fires in "timers" phase — after I/O in same iteration
+
+---
+
+## Modern JS Cheat Sheet (ES2022–2025)
+
+| Feature | Year | Usage |
+|---------|------|-------|
+| `Array.at(-1)` | ES2022 | Last element without `.length - 1` |
+| `Object.hasOwn(obj, key)` | ES2022 | Replaces `obj.hasOwnProperty(key)` |
+| `#privateField` in class | ES2022 | True private (not just convention) |
+| `static {}` class block | ES2022 | One-time class initialization |
+| Top-level `await` | ES2022 | `await` at module top — ESM only |
+| `Error cause` | ES2022 | `new Error('msg', { cause: err })` |
+| `structuredClone(obj)` | ES2022 | Deep clone — built-in, no lodash |
+| `Array.findLast()` | ES2023 | Find from end |
+| `WeakMap(Symbol)` | ES2023 | Symbols as WeakMap keys |
+| `Object.groupBy(iter, fn)` | ES2024 | Group into plain object |
+| `Map.groupBy(iter, fn)` | ES2024 | Group into Map |
+| `Promise.withResolvers()` | ES2024 | Deferred pattern |
+| `ArrayBuffer.prototype.resize()` | ES2024 | Grow/shrink buffer in-place |
+| `String.prototype.isWellFormed()` | ES2024 | Check valid Unicode |
+| `import assert { type: 'json' }` | ES2024 | Import attributes |
+| `Set.prototype.union(other)` | ES2025 | Set algebra methods |
+| `Set.prototype.intersection(other)` | ES2025 | Set algebra methods |
+| `Set.prototype.difference(other)` | ES2025 | Set algebra methods |
+| Iterator helpers (`map`, `filter`, `take`…) | ES2025 | Lazy iterator protocol |
+| `using` / `Symbol.dispose` | ES2025 | Explicit resource management |
+| `Temporal` API | Stage 3 | Modern date/time (replaces Date) |
+| `import defer` | Stage 3 | Deferred module evaluation |
+
+---
+
+## Node.js Quick Start
+
+```javascript
+// Built-in test runner (Node 18+, stable in Node 20)
+import { describe, it, before, after, mock } from 'node:test';
+import assert from 'node:assert/strict';
+
+describe('my module', () => {
+  it('adds numbers', () => {
+    assert.equal(1 + 1, 2);
+  });
+});
+
+// Run: node --test
+// Watch: node --test --watch
+// Coverage: node --test --experimental-test-coverage
+```
+
+```javascript
+// fs/promises — built-in, no third-party needed
+import { readFile, writeFile, readdir } from 'node:fs/promises';
+
+const content = await readFile('./config.json', 'utf8');
+const files = await readdir('./src', { recursive: true }); // Node 18.17+
+
+// .env loading — Node 21+ (no dotenv package required)
+// node --env-file=.env server.js
+```
+
+**Key built-in modules:**
+
+| Module | Purpose |
+|--------|---------|
+| `node:fs/promises` | Async file system |
+| `node:path` | Path manipulation |
+| `node:url` | URL parsing, `fileURLToPath` |
+| `node:crypto` | Hashing, encryption, UUIDs |
+| `node:stream` | Streams + `pipeline()` |
+| `node:worker_threads` | CPU parallelism |
+| `node:child_process` | Subprocess execution |
+| `node:test` | Built-in test runner |
+| `node:http` / `node:http2` | HTTP servers |
+| `node:diagnostics_channel` | Observability hooks |
+| `node:perf_hooks` | Performance measurement |
+
+---
+
+## Error Handling Patterns
+
+```javascript
+// 1. Standard async try/catch
+async function fetchUser(id) {
+  try {
+    const res = await fetch(`/api/users/${id}`);
+    if (!res.ok) throw new Error(`HTTP ${res.status}`, { cause: res });
+    return await res.json();
+  } catch (err) {
+    console.error('fetchUser failed:', err);
+    throw err; // re-throw unless you can recover
+  }
+}
+
+// 2. Abort with timeout (Node 17.3+ / browsers)
+const signal = AbortSignal.timeout(5000);
+const res = await fetch(url, { signal });
+
+// 3. Global unhandled rejection handler
+process.on('unhandledRejection', (reason, promise) => {
+  console.error('Unhandled rejection:', reason);
+  process.exit(1); // always exit — unknown state
+});
+
+// 4. AggregateError — wraps multiple errors
+const results = await Promise.allSettled([a(), b(), c()]);
+const failures = results.filter(r => r.status === 'rejected');
+if (failures.length) {
+  throw new AggregateError(failures.map(f => f.reason), 'Multiple failures');
+}
+
+// 5. Custom Error class
+class AppError extends Error {
+  constructor(message, { code, cause } = {}) {
+    super(message, { cause });
+    this.name = 'AppError';
+    this.code = code;
+  }
+}
+```
+
+---
+
+## Common Gotchas
+
+| Gotcha | Why | Fix |
+|--------|-----|-----|
+| `this` is `undefined` in callback | Arrow functions capture `this` lexically; regular functions don't | Use arrow function or `.bind(this)` |
+| Closure captures loop variable `var i` | `var` is function-scoped; all closures share same `i` | Use `let i` (block-scoped) or `.forEach` |
+| `==` treats `null == undefined` as true | Loose equality does type coercion | Always use `===` and `!==` |
+| `0.1 + 0.2 !== 0.3` | IEEE 754 floating-point precision | `Math.round(n * 1e10) / 1e10` or use `Number.EPSILON` comparison |
+| `a?.b ?? c` vs `a?.b \|\| c` | `??` only falls back on `null`/`undefined`; `\|\|` on any falsy | Use `??` when 0 or `""` are valid values |
+| `typeof null === 'object'` | Historic JavaScript bug | Check `val === null` explicitly |
+| `[3,10,2].sort()` → `[10,2,3]` | Default sort converts to strings | Provide comparator: `.sort((a,b) => a - b)` |
+| `parseInt('08')` → 8 in modern, 0 in old | Octal parsing in pre-ES5 | Always pass radix: `parseInt(str, 10)` |
+| `for...in` on arrays | Iterates inherited enumerable properties too | Use `for...of` or `.forEach()` for arrays |
+| `Promise.all` fails fast | One rejection cancels all (others still run) | Use `Promise.allSettled` if you need all results |
+| `JSON.stringify` drops `undefined` / functions / Symbols | Not JSON-serializable | Convert to `null` first or use a replacer function |
+| Async function always returns a Promise | `async () => 42` returns `Promise<42>`, not `42` | `await` the call site or chain `.then()` |
+
+---
+
+## Reference Files
+
+| File | When to Load |
+|------|-------------|
+| `references/async-patterns.md` | Promise combinators, async iterators, AbortController, Streams, Web Workers, structured concurrency |
+| `references/modules-runtime.md` | ESM/CJS/dual packages, dynamic import, V8 internals, memory management, event loop deep dive |
+| `references/modern-features.md` | ES2022-2025 feature details, Proxy/Reflect, Decorators, Temporal, Explicit Resource Management |
+| `references/node-patterns.md` | node:test runner, fs/promises, worker_threads, streams, crypto, graceful shutdown, permission model |
+
+---
+
+## See Also
+
+- `typescript-ops` — TypeScript types, generics, utility types, tsconfig
+- `react-ops` — React hooks, Server Components, state management
+- `vue-ops` — Vue 3 Composition API, Pinia, Nuxt
+- `testing-ops` — Jest, Vitest, Playwright, TDD patterns

+ 0 - 0
skills/javascript-ops/assets/.gitkeep


+ 878 - 0
skills/javascript-ops/references/async-patterns.md

@@ -0,0 +1,878 @@
+# Async Patterns Reference
+
+Comprehensive patterns for asynchronous JavaScript — Promises, async/await, iterators, streams, workers, and cancellation.
+
+---
+
+## Promise Fundamentals
+
+### Constructor
+
+```javascript
+// Only use the constructor to wrap callback-based APIs
+function readFileAsync(path) {
+  return new Promise((resolve, reject) => {
+    fs.readFile(path, 'utf8', (err, data) => {
+      if (err) reject(err);
+      else resolve(data);
+    });
+  });
+}
+
+// Anti-pattern: Promise constructor around another Promise
+// BAD — "deferred anti-pattern"
+const p = new Promise((resolve) => {
+  fetch('/api/data').then(resolve); // don't do this
+});
+
+// GOOD — just return the Promise directly
+const p = fetch('/api/data');
+```
+
+### then / catch / finally
+
+```javascript
+fetch('/api/users')
+  .then(res => {
+    if (!res.ok) throw new Error(`HTTP ${res.status}`);
+    return res.json(); // return value becomes next then's input
+  })
+  .then(users => console.log(users))
+  .catch(err => {
+    // catches ALL errors from any then() above
+    console.error('Failed:', err);
+    return []; // recover — return value continues the chain
+  })
+  .finally(() => {
+    // always runs, receives no argument, does NOT change resolved value
+    hideLoadingSpinner();
+  });
+```
+
+### Chaining vs Nesting
+
+```javascript
+// BAD — nested (pyramid of doom with Promises)
+fetch('/api/user')
+  .then(res => res.json().then(user => {
+    fetch(`/api/posts/${user.id}`).then(res => res.json().then(posts => {
+      console.log(posts);
+    }));
+  }));
+
+// GOOD — flat chain
+fetch('/api/user')
+  .then(res => res.json())
+  .then(user => fetch(`/api/posts/${user.id}`))
+  .then(res => res.json())
+  .then(posts => console.log(posts));
+
+// BEST — async/await
+const res = await fetch('/api/user');
+const user = await res.json();
+const postsRes = await fetch(`/api/posts/${user.id}`);
+const posts = await postsRes.json();
+```
+
+---
+
+## Promise Combinators
+
+### Promise.all — All must succeed
+
+```javascript
+// Runs in parallel, fails fast on first rejection
+const [user, posts, comments] = await Promise.all([
+  fetchUser(userId),
+  fetchPosts(userId),
+  fetchComments(userId),
+]);
+
+// With error handling — if any rejects, the whole thing rejects
+try {
+  const results = await Promise.all([a(), b(), c()]);
+} catch (err) {
+  // err is from whichever promise rejected first
+  // The other promises are NOT cancelled (still running!)
+}
+
+// Use case: multiple independent API calls that all must succeed
+async function loadDashboard(userId) {
+  const [profile, analytics, notifications] = await Promise.all([
+    api.getProfile(userId),
+    api.getAnalytics(userId),
+    api.getNotifications(userId),
+  ]);
+  return { profile, analytics, notifications };
+}
+```
+
+### Promise.allSettled — Handle each individually
+
+```javascript
+// Never rejects — always resolves with array of outcome objects
+const results = await Promise.allSettled([
+  fetch('/api/primary'),
+  fetch('/api/secondary'),
+  fetch('/api/tertiary'),
+]);
+
+for (const result of results) {
+  if (result.status === 'fulfilled') {
+    console.log('Success:', result.value);
+  } else {
+    console.warn('Failed:', result.reason);
+  }
+}
+
+// Use case: batch operations where partial success is acceptable
+async function syncAllRecords(records) {
+  const results = await Promise.allSettled(
+    records.map(r => syncRecord(r))
+  );
+  const failures = results.filter(r => r.status === 'rejected');
+  if (failures.length > 0) {
+    console.warn(`${failures.length} of ${records.length} records failed to sync`);
+  }
+  return results;
+}
+```
+
+### Promise.race — First settled wins
+
+```javascript
+// Resolves or rejects with the FIRST settled promise (fulfilled OR rejected)
+const result = await Promise.race([
+  fetch('/api/data'),
+  new Promise((_, reject) =>
+    setTimeout(() => reject(new Error('Timeout')), 5000)
+  ),
+]);
+
+// Use case: implement timeout (prefer AbortSignal.timeout() in modern code)
+function withTimeout(promise, ms) {
+  const timeout = new Promise((_, reject) =>
+    setTimeout(() => reject(new Error(`Operation timed out after ${ms}ms`)), ms)
+  );
+  return Promise.race([promise, timeout]);
+}
+
+// Use case: racing multiple endpoints (first one wins)
+const data = await Promise.race([
+  fetchFromPrimary(),
+  fetchFromFallback(),
+]);
+```
+
+### Promise.any — First success wins
+
+```javascript
+// Resolves with FIRST fulfilled promise; rejects (AggregateError) only if ALL reject
+try {
+  const data = await Promise.any([
+    fetchFromCDN1(),
+    fetchFromCDN2(),
+    fetchFromOrigin(),
+  ]);
+  console.log('Got data from fastest source:', data);
+} catch (err) {
+  // err is AggregateError — all failed
+  console.error('All sources failed:', err.errors);
+}
+
+// Use case: redundant data sources with automatic fallback
+async function fetchWithFallback(urls) {
+  return Promise.any(urls.map(url => fetch(url).then(r => r.json())));
+}
+```
+
+---
+
+## async/await Patterns
+
+### Sequential vs Parallel
+
+```javascript
+// SEQUENTIAL — each waits for the previous (slow if independent)
+const user = await getUser(id);
+const posts = await getPosts(id);     // waits for getUser to finish first
+const friends = await getFriends(id); // waits for getPosts to finish first
+
+// PARALLEL — all start together (fast)
+const [user, posts, friends] = await Promise.all([
+  getUser(id),
+  getPosts(id),
+  getFriends(id),
+]);
+
+// MIXED — some sequential dependencies, some parallel
+const user = await getUser(id); // must come first
+const [posts, preferences] = await Promise.all([
+  getPosts(user.blogId),         // depends on user
+  getPreferences(user.settingsId), // depends on user
+]);
+```
+
+### Error Handling
+
+```javascript
+// 1. try/catch — most readable
+async function loadUser(id) {
+  try {
+    const res = await fetch(`/api/users/${id}`);
+    if (!res.ok) throw new Error(`HTTP ${res.status}`, { cause: res });
+    return await res.json();
+  } catch (err) {
+    if (err.name === 'AbortError') return null; // cancelled — not an error
+    throw err; // re-throw unexpected errors
+  }
+}
+
+// 2. Inline error handling for parallel calls
+const [userResult, postsResult] = await Promise.allSettled([
+  getUser(id),
+  getPosts(id),
+]);
+const user = userResult.status === 'fulfilled' ? userResult.value : null;
+
+// 3. Utility: "safe" wrapper that returns [error, result]
+async function safe(promise) {
+  try {
+    return [null, await promise];
+  } catch (err) {
+    return [err, null];
+  }
+}
+
+const [err, user] = await safe(getUser(id));
+if (err) { /* handle */ }
+```
+
+### Top-Level Await (ESM only)
+
+```javascript
+// Works at the top level of an ES module — no async wrapper needed
+// file: config.mjs
+const config = await fetch('/api/config').then(r => r.json());
+
+export const DB_URL = config.dbUrl;
+export const API_KEY = config.apiKey;
+
+// Importers of this module will wait for the top-level awaits to settle
+// import { DB_URL } from './config.mjs'; // already resolved by the time you use it
+```
+
+---
+
+## Promise.withResolvers — Deferred Pattern
+
+```javascript
+// ES2024 — cleaner deferred pattern
+const { promise, resolve, reject } = Promise.withResolvers();
+
+// Classic use case: expose resolve/reject to external code
+class EventQueue {
+  #waiters = [];
+
+  async waitForNext() {
+    const { promise, resolve } = Promise.withResolvers();
+    this.#waiters.push(resolve);
+    return promise;
+  }
+
+  emit(value) {
+    const resolve = this.#waiters.shift();
+    resolve?.(value);
+  }
+}
+
+// Use case: WebSocket message waiting
+class WebSocketClient {
+  #pending = new Map();
+  #counter = 0;
+
+  send(data) {
+    const id = ++this.#counter;
+    const { promise, resolve, reject } = Promise.withResolvers();
+    this.#pending.set(id, { resolve, reject });
+    this.#ws.send(JSON.stringify({ id, data }));
+    return promise;
+  }
+
+  #onMessage(event) {
+    const { id, result, error } = JSON.parse(event.data);
+    const { resolve, reject } = this.#pending.get(id) ?? {};
+    this.#pending.delete(id);
+    if (error) reject(new Error(error));
+    else resolve(result);
+  }
+}
+
+// Before ES2024 — manual deferred
+function deferred() {
+  let resolve, reject;
+  const promise = new Promise((res, rej) => {
+    resolve = res;
+    reject = rej;
+  });
+  return { promise, resolve, reject };
+}
+```
+
+---
+
+## Async Iterators
+
+### for await...of
+
+```javascript
+// Works with any object implementing Symbol.asyncIterator
+async function processLargeFile(filePath) {
+  const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
+  for await (const chunk of stream) {
+    await processChunk(chunk);
+  }
+}
+
+// Paginated API — async iterator wraps pagination logic
+async function* paginate(url) {
+  let nextUrl = url;
+  while (nextUrl) {
+    const res = await fetch(nextUrl);
+    const { data, next } = await res.json();
+    yield* data;        // yield each item individually
+    nextUrl = next;     // null/undefined stops the loop
+  }
+}
+
+for await (const user of paginate('/api/users?limit=100')) {
+  await processUser(user);
+}
+```
+
+### Custom Async Generators
+
+```javascript
+// Async generator function — yields Promises
+async function* generateNumbers(start, end, delayMs = 100) {
+  for (let i = start; i <= end; i++) {
+    await new Promise(r => setTimeout(r, delayMs));
+    yield i;
+  }
+}
+
+// Async generator for event-based sources
+async function* fromEventTarget(target, event, { signal } = {}) {
+  const { promise, resolve } = Promise.withResolvers();
+  const handler = (e) => resolve(e);
+  target.addEventListener(event, handler, { once: true, signal });
+  try {
+    yield await promise;
+    // Recurse to get next event
+    yield* fromEventTarget(target, event, { signal });
+  } finally {
+    target.removeEventListener(event, handler);
+  }
+}
+
+// Consuming with early exit
+const controller = new AbortController();
+for await (const event of fromEventTarget(emitter, 'data', { signal: controller.signal })) {
+  processEvent(event);
+  if (shouldStop) controller.abort();
+}
+```
+
+### Symbol.asyncIterator
+
+```javascript
+class AsyncRange {
+  constructor(start, end, delay = 0) {
+    this.start = start;
+    this.end = end;
+    this.delay = delay;
+  }
+
+  [Symbol.asyncIterator]() {
+    let current = this.start;
+    const { end, delay } = this;
+    return {
+      async next() {
+        if (delay) await new Promise(r => setTimeout(r, delay));
+        if (current <= end) {
+          return { value: current++, done: false };
+        }
+        return { value: undefined, done: true };
+      },
+      return() {
+        // Called when consumer breaks early
+        return { value: undefined, done: true };
+      }
+    };
+  }
+}
+
+for await (const n of new AsyncRange(1, 5, 100)) {
+  console.log(n); // 1, 2, 3, 4, 5 with 100ms between each
+}
+```
+
+---
+
+## AbortController — Cancellation
+
+### Fetch Cancellation
+
+```javascript
+// Cancel an in-flight fetch
+const controller = new AbortController();
+
+const button = document.querySelector('#cancel');
+button.addEventListener('click', () => controller.abort());
+
+try {
+  const res = await fetch('/api/large-export', {
+    signal: controller.signal,
+  });
+  const data = await res.json();
+} catch (err) {
+  if (err.name === 'AbortError') {
+    console.log('Request was cancelled');
+  } else {
+    throw err;
+  }
+}
+```
+
+### AbortSignal.timeout — Built-in Timeout
+
+```javascript
+// No need for Promise.race with a timeout Promise anymore
+const res = await fetch('/api/data', {
+  signal: AbortSignal.timeout(5000), // throws TimeoutError after 5s
+});
+
+// TimeoutError is a subclass of DOMException
+try {
+  const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
+} catch (err) {
+  if (err.name === 'TimeoutError') {
+    console.log('Request timed out');
+  }
+}
+```
+
+### AbortSignal.any — Combine Signals
+
+```javascript
+// Cancel if EITHER the user aborts OR we time out
+const userController = new AbortController();
+const combined = AbortSignal.any([
+  userController.signal,
+  AbortSignal.timeout(10_000),
+]);
+
+const res = await fetch(url, { signal: combined });
+```
+
+### Custom Abortable Operations
+
+```javascript
+// Make your own async functions respect abort signals
+async function delay(ms, { signal } = {}) {
+  return new Promise((resolve, reject) => {
+    const timer = setTimeout(resolve, ms);
+    signal?.addEventListener('abort', () => {
+      clearTimeout(timer);
+      reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
+    }, { once: true });
+  });
+}
+
+// Propagate cancellation through a chain of operations
+async function processItems(items, { signal } = {}) {
+  for (const item of items) {
+    signal?.throwIfAborted(); // throws if already aborted
+    await processItem(item, { signal });
+  }
+}
+```
+
+---
+
+## Streams API
+
+### ReadableStream
+
+```javascript
+// Create a readable stream from scratch
+const stream = new ReadableStream({
+  start(controller) {
+    // Called once when stream is created
+  },
+  async pull(controller) {
+    // Called when consumer wants more data
+    const chunk = await getNextChunk();
+    if (chunk === null) {
+      controller.close();
+    } else {
+      controller.enqueue(chunk);
+    }
+  },
+  cancel(reason) {
+    // Called if consumer cancels
+    cleanup();
+  },
+});
+
+// Consume with for await
+const reader = stream.getReader();
+try {
+  while (true) {
+    const { done, value } = await reader.read();
+    if (done) break;
+    process(value);
+  }
+} finally {
+  reader.releaseLock();
+}
+
+// Or use for-await-of (web streams are async iterable in modern browsers/Node 18+)
+for await (const chunk of stream) {
+  process(chunk);
+}
+```
+
+### WritableStream
+
+```javascript
+const writable = new WritableStream({
+  async write(chunk, controller) {
+    await persistChunk(chunk);
+  },
+  close() {
+    console.log('Stream finished');
+  },
+  abort(err) {
+    console.error('Stream aborted:', err);
+  },
+});
+
+const writer = writable.getWriter();
+await writer.write('chunk 1');
+await writer.write('chunk 2');
+await writer.close();
+```
+
+### TransformStream — Data Transformation in Flight
+
+```javascript
+// Parse newline-delimited JSON (NDJSON)
+class NDJSONParser extends TransformStream {
+  constructor() {
+    let buffer = '';
+    super({
+      transform(chunk, controller) {
+        buffer += chunk;
+        const lines = buffer.split('\n');
+        buffer = lines.pop(); // last line may be incomplete
+        for (const line of lines) {
+          if (line.trim()) {
+            controller.enqueue(JSON.parse(line));
+          }
+        }
+      },
+      flush(controller) {
+        if (buffer.trim()) {
+          controller.enqueue(JSON.parse(buffer));
+        }
+      },
+    });
+  }
+}
+
+// Usage: fetch NDJSON and stream parsed objects
+const response = await fetch('/api/export.ndjson');
+const objectStream = response.body
+  .pipeThrough(new TextDecoderStream())
+  .pipeThrough(new NDJSONParser());
+
+for await (const record of objectStream) {
+  await insertRecord(record);
+}
+```
+
+### Node.js Streams — pipeline()
+
+```javascript
+import { pipeline } from 'node:stream/promises';
+import { createReadStream, createWriteStream } from 'node:fs';
+import { createGzip } from 'node:zlib';
+import { Transform } from 'node:stream';
+
+// pipeline() properly handles errors and cleanup
+await pipeline(
+  createReadStream('./input.csv'),
+  new Transform({
+    objectMode: true,
+    transform(chunk, encoding, callback) {
+      // Transform each chunk
+      const processed = processCSVChunk(chunk.toString());
+      callback(null, processed);
+    },
+  }),
+  createGzip(),
+  createWriteStream('./output.csv.gz'),
+);
+```
+
+### Backpressure
+
+```javascript
+// Writer respects backpressure automatically
+const writer = writable.getWriter();
+
+for (const chunk of largeDataset) {
+  // write() returns a Promise that resolves when ready for more
+  await writer.write(chunk);
+}
+```
+
+---
+
+## Web Workers
+
+### Basic Worker (browser)
+
+```javascript
+// main.js
+const worker = new Worker('./worker.js', { type: 'module' });
+
+worker.postMessage({ type: 'COMPUTE', data: largeArray });
+
+worker.addEventListener('message', (event) => {
+  console.log('Result:', event.data.result);
+});
+
+worker.addEventListener('error', (event) => {
+  console.error('Worker error:', event.message);
+});
+
+// Terminate when done
+worker.terminate();
+```
+
+```javascript
+// worker.js
+self.addEventListener('message', async (event) => {
+  const { type, data } = event.data;
+  if (type === 'COMPUTE') {
+    const result = heavyComputation(data);
+    self.postMessage({ result });
+  }
+});
+```
+
+### Transferable Objects — Zero-Copy Transfer
+
+```javascript
+// Transfer ownership of ArrayBuffer — zero-copy, no serialization
+const buffer = new ArrayBuffer(1024 * 1024 * 100); // 100MB
+const view = new Uint8Array(buffer);
+
+// After transfer, buffer is detached in the sender
+worker.postMessage({ buffer }, [buffer]); // second arg = transferables
+// buffer.byteLength === 0 now — ownership transferred
+
+// In worker.js
+self.addEventListener('message', ({ data: { buffer } }) => {
+  const view = new Uint8Array(buffer);
+  // ... process ...
+  // Transfer back
+  self.postMessage({ buffer }, [buffer]);
+});
+```
+
+### SharedArrayBuffer + Atomics
+
+```javascript
+// SharedArrayBuffer — both threads read/write same memory
+// Requires Cross-Origin Isolation: COOP + COEP headers
+
+// main.js
+const shared = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 10);
+const array = new Int32Array(shared);
+
+worker.postMessage({ shared }); // no transfer needed — it's shared
+
+// Atomic operations prevent data races
+Atomics.add(array, 0, 1);          // atomic increment
+const val = Atomics.load(array, 0); // atomic read
+Atomics.store(array, 0, 42);        // atomic write
+const exchanged = Atomics.compareExchange(array, 0, expected, replacement);
+
+// Atomic wait/notify (for coordination between threads)
+// worker: wait for index 0 to change from 0
+Atomics.wait(array, 0, 0); // blocks worker thread
+
+// main: signal worker
+Atomics.store(array, 0, 1);
+Atomics.notify(array, 0, 1); // wake one waiter
+```
+
+### MessageChannel — Direct Worker-to-Worker Communication
+
+```javascript
+const channel = new MessageChannel();
+const worker1 = new Worker('./worker1.js');
+const worker2 = new Worker('./worker2.js');
+
+// Give each worker a port — they can now talk directly
+worker1.postMessage({ port: channel.port1 }, [channel.port1]);
+worker2.postMessage({ port: channel.port2 }, [channel.port2]);
+
+// In worker1.js
+self.addEventListener('message', ({ data: { port } }) => {
+  port.addEventListener('message', (e) => console.log('From worker2:', e.data));
+  port.start();
+  port.postMessage('Hello from worker1');
+});
+```
+
+---
+
+## Structured Concurrency — Cancellation Propagation
+
+```javascript
+// Cancel a group of operations atomically
+async function fetchWithCleanup(urls, { signal } = {}) {
+  const controller = new AbortController();
+
+  // Cancel our internal controller if the parent signal fires
+  signal?.addEventListener('abort', () => controller.abort(signal.reason), {
+    once: true,
+    signal: controller.signal, // auto-remove listener when we're done
+  });
+
+  try {
+    return await Promise.all(
+      urls.map(url => fetch(url, { signal: controller.signal }))
+    );
+  } catch (err) {
+    controller.abort(err); // cancel remaining on first failure
+    throw err;
+  }
+}
+
+// Race with cleanup: cancel losers when winner is found
+async function raceWithCleanup(operations) {
+  const controller = new AbortController();
+  try {
+    return await Promise.any(
+      operations.map(op => op({ signal: controller.signal }))
+    );
+  } finally {
+    controller.abort(); // cancel all remaining operations
+  }
+}
+```
+
+---
+
+## Error Handling in Async Code
+
+### Global Unhandled Rejection Handlers
+
+```javascript
+// Browser
+window.addEventListener('unhandledrejection', (event) => {
+  console.error('Unhandled promise rejection:', event.reason);
+  event.preventDefault(); // prevent default browser behavior (console error)
+});
+
+window.addEventListener('rejectionhandled', (event) => {
+  console.log('Previously unhandled rejection was handled:', event.promise);
+});
+
+// Node.js
+process.on('unhandledRejection', (reason, promise) => {
+  console.error('Unhandled rejection at:', promise, 'reason:', reason);
+  process.exit(1); // crash — unknown state is dangerous
+});
+
+process.on('uncaughtException', (err) => {
+  console.error('Uncaught exception:', err);
+  process.exit(1);
+});
+```
+
+### Async Stack Traces
+
+```javascript
+// Node.js — enable async stack traces (slight performance cost)
+// node --async-stack-traces script.js  (default in Node 12+)
+
+// The Error.captureStackTrace pattern
+class AsyncError extends Error {
+  constructor(message, options) {
+    super(message, options);
+    this.name = 'AsyncError';
+    // Stack trace starts here, not inside Error constructor
+    Error.captureStackTrace?.(this, AsyncError);
+  }
+}
+
+// Preserving context across async boundaries with cause
+async function loadConfig(path) {
+  try {
+    const text = await readFile(path, 'utf8');
+    return JSON.parse(text);
+  } catch (err) {
+    throw new Error(`Failed to load config from ${path}`, { cause: err });
+  }
+}
+```
+
+---
+
+## Anti-Patterns to Avoid
+
+```javascript
+// BAD: async void — fire and forget, errors are silently swallowed
+someButton.addEventListener('click', async () => {
+  await doSomething(); // if this throws, nobody knows
+});
+
+// GOOD: handle the error
+someButton.addEventListener('click', () => {
+  doSomething().catch(err => showErrorToUser(err));
+});
+
+// BAD: floating promise (not awaited, not .catch'd)
+function processItem(item) {
+  fetch('/api/track'); // fire and forget — may fail silently
+  return transformItem(item);
+}
+
+// BAD: Promise constructor wrapping a Promise
+const p = new Promise(resolve => resolve(fetch(url))); // unnecessary wrapping
+
+// BAD: sequential await where parallel is possible
+const a = await getA();
+const b = await getB(); // waits unnecessarily
+const c = await getC(); // waits unnecessarily
+
+// GOOD
+const [a, b, c] = await Promise.all([getA(), getB(), getC()]);
+
+// BAD: .then() inside async function (mixing styles)
+async function getData() {
+  return fetch(url).then(r => r.json()); // confusing mix
+}
+
+// GOOD: consistent async/await
+async function getData() {
+  const res = await fetch(url);
+  return res.json();
+}
+```

+ 757 - 0
skills/javascript-ops/references/modern-features.md

@@ -0,0 +1,757 @@
+# Modern JavaScript Features Reference
+
+ES2022 through ES2025, stage 3 proposals, and advanced runtime features.
+
+---
+
+## ES2022 Features
+
+### Top-Level Await
+
+```javascript
+// ESM only — works at the top level of a module, no async wrapper
+import { createPool } from 'pg';
+
+// Module-level async initialization
+const pool = await createPool({ connectionString: process.env.DATABASE_URL });
+
+export async function query(sql, params) {
+  return pool.query(sql, params);
+}
+
+// Use case: lazy initialization that must complete before module is usable
+const config = await fetch('/api/config').then(r => r.json());
+
+export const API_URL = config.apiUrl;
+export const TIMEOUT = config.timeout;
+```
+
+### Private Class Fields and Methods
+
+```javascript
+class BankAccount {
+  // Private field — truly inaccessible from outside
+  #balance = 0;
+  #transactionLog = [];
+
+  // Private method
+  #recordTransaction(amount, type) {
+    this.#transactionLog.push({ amount, type, at: new Date() });
+  }
+
+  // Private static field
+  static #instanceCount = 0;
+
+  constructor(initialBalance) {
+    this.#balance = initialBalance;
+    BankAccount.#instanceCount++;
+  }
+
+  deposit(amount) {
+    if (amount <= 0) throw new Error('Amount must be positive');
+    this.#balance += amount;
+    this.#recordTransaction(amount, 'deposit');
+  }
+
+  withdraw(amount) {
+    if (amount > this.#balance) throw new Error('Insufficient funds');
+    this.#balance -= amount;
+    this.#recordTransaction(amount, 'withdrawal');
+  }
+
+  get balance() { return this.#balance; }
+
+  // Check private field existence — 'in' operator works with private fields
+  static isAccount(obj) {
+    return #balance in obj; // true if obj has this private field
+  }
+
+  static get count() { return BankAccount.#instanceCount; }
+}
+
+const account = new BankAccount(1000);
+account.deposit(500);
+// account.#balance  // SyntaxError — genuinely inaccessible
+BankAccount.isAccount(account); // true
+```
+
+### Static Class Blocks
+
+```javascript
+class Config {
+  static DEBUG;
+  static API_URL;
+  static #initialized = false;
+
+  // Static initialization block — runs once when class is defined
+  static {
+    // Can contain arbitrary initialization logic
+    Config.DEBUG = process.env.NODE_ENV !== 'production';
+    Config.API_URL = process.env.API_URL ?? 'https://api.example.com';
+    Config.#initialized = true;
+    console.log('Config initialized');
+  }
+
+  static isReady() { return Config.#initialized; }
+}
+
+// Use case: initialize from multiple sources, run try/catch
+class Database {
+  static connection;
+  static error;
+
+  static {
+    try {
+      Database.connection = createConnection(process.env.DB_URL);
+    } catch (err) {
+      Database.error = err;
+    }
+  }
+}
+```
+
+### Array.at() — Negative Indexing
+
+```javascript
+const arr = [1, 2, 3, 4, 5];
+
+// Before at()
+arr[arr.length - 1]; // 5 — verbose
+arr.slice(-1)[0];    // 5 — creates a new array
+
+// With at()
+arr.at(-1);   // 5 — last element
+arr.at(-2);   // 4 — second to last
+arr.at(0);    // 1 — first element (same as arr[0])
+arr.at(1);    // 2
+
+// Also works on strings and TypedArrays
+'hello'.at(-1); // 'o'
+new Uint8Array([10, 20, 30]).at(-1); // 30
+```
+
+### Object.hasOwn() — Safe hasOwnProperty
+
+```javascript
+// obj.hasOwnProperty is unsafe — can be overridden or null prototype
+const obj = Object.create(null); // no prototype
+// obj.hasOwnProperty('key'); // TypeError: not a function
+
+// Object.hasOwn is always safe
+Object.hasOwn(obj, 'key');     // false
+Object.hasOwn({ a: 1 }, 'a'); // true
+Object.hasOwn({ a: 1 }, 'b'); // false
+
+// Replaces the classic idiom:
+Object.prototype.hasOwnProperty.call(obj, 'key');
+```
+
+### Error cause — Error Chaining
+
+```javascript
+// Attach the original error to provide context
+async function fetchUser(id) {
+  try {
+    const res = await fetch(`/api/users/${id}`);
+    return await res.json();
+  } catch (err) {
+    // Wrap with context, preserve original
+    throw new Error(`Failed to fetch user ${id}`, { cause: err });
+  }
+}
+
+// Access the chain
+try {
+  await fetchUser(42);
+} catch (err) {
+  console.error(err.message);       // "Failed to fetch user 42"
+  console.error(err.cause.message); // original network error
+}
+
+// Works with custom error classes
+class DatabaseError extends Error {
+  constructor(message, { cause, query } = {}) {
+    super(message, { cause });
+    this.name = 'DatabaseError';
+    this.query = query;
+  }
+}
+```
+
+### structuredClone() — Deep Clone Built-In
+
+```javascript
+// No more lodash.cloneDeep or JSON.parse(JSON.stringify(...))
+const original = {
+  name: 'Alice',
+  scores: [1, 2, 3],
+  metadata: { created: new Date(), tags: new Set(['js', 'node']) },
+};
+
+const clone = structuredClone(original);
+clone.scores.push(4);
+clone.metadata.tags.add('es2022');
+
+original.scores;           // [1, 2, 3] — unchanged
+original.metadata.tags;   // Set {'js', 'node'} — unchanged
+
+// Supports: Date, RegExp, Map, Set, ArrayBuffer, TypedArray, Blob, File, etc.
+// Does NOT support: functions, class instances (becomes plain object), Symbol values
+
+// Transfer ownership of ArrayBuffer while cloning the rest
+const { buffer } = structuredClone(
+  { name: 'test', data: new ArrayBuffer(1024) },
+  { transfer: [original.data] } // transfer the buffer
+);
+```
+
+---
+
+## ES2023 Features
+
+### Array.findLast() and Array.findLastIndex()
+
+```javascript
+const events = [
+  { type: 'login', at: '08:00' },
+  { type: 'purchase', at: '09:30' },
+  { type: 'login', at: '14:00' },
+  { type: 'logout', at: '17:00' },
+];
+
+// Find last login event
+const lastLogin = events.findLast(e => e.type === 'login');
+// { type: 'login', at: '14:00' }
+
+// Before findLast — verbose
+const lastLogin2 = [...events].reverse().find(e => e.type === 'login');
+
+// findLastIndex — returns index, -1 if not found
+const lastLoginIdx = events.findLastIndex(e => e.type === 'login'); // 2
+```
+
+### WeakMap with Symbol Keys
+
+```javascript
+// Previously only objects could be WeakMap keys
+// Now Symbols (non-registered, non-global) can be WeakMap keys
+
+const key = Symbol('private-data');
+const weakMap = new WeakMap();
+
+function attachPrivateData(obj, data) {
+  weakMap.set(key, { obj, data }); // Symbol as key
+}
+
+// Use case: library code that attaches private metadata to symbols
+```
+
+---
+
+## ES2024 Features
+
+### Object.groupBy() and Map.groupBy()
+
+```javascript
+const products = [
+  { name: 'Apple', category: 'fruit', price: 1.2 },
+  { name: 'Banana', category: 'fruit', price: 0.5 },
+  { name: 'Carrot', category: 'vegetable', price: 0.8 },
+  { name: 'Broccoli', category: 'vegetable', price: 1.5 },
+];
+
+// Object.groupBy — groups into plain object
+const byCategory = Object.groupBy(products, ({ category }) => category);
+// {
+//   fruit: [{ name: 'Apple', ... }, { name: 'Banana', ... }],
+//   vegetable: [{ name: 'Carrot', ... }, { name: 'Broccoli', ... }]
+// }
+
+// Map.groupBy — groups into Map (preserves key type, any key type works)
+const byPriceRange = Map.groupBy(products, ({ price }) => {
+  if (price < 1) return 'budget';
+  if (price < 2) return 'mid';
+  return 'premium';
+});
+byPriceRange.get('budget'); // [{ name: 'Banana', ... }, { name: 'Carrot', ... }]
+```
+
+### Promise.withResolvers()
+
+```javascript
+// See async-patterns.md for full coverage
+const { promise, resolve, reject } = Promise.withResolvers();
+
+// Enables clean deferred patterns
+class Semaphore {
+  #count;
+  #queue = [];
+
+  constructor(count) { this.#count = count; }
+
+  async acquire() {
+    if (this.#count > 0) {
+      this.#count--;
+      return;
+    }
+    const { promise, resolve } = Promise.withResolvers();
+    this.#queue.push(resolve);
+    await promise;
+  }
+
+  release() {
+    const resolve = this.#queue.shift();
+    if (resolve) {
+      resolve();
+    } else {
+      this.#count++;
+    }
+  }
+}
+```
+
+### ArrayBuffer.prototype.resize() and transferToFixedLength()
+
+```javascript
+// Create a resizable ArrayBuffer
+const buffer = new ArrayBuffer(1024, { maxByteLength: 1024 * 1024 }); // max 1MB
+
+const view = new Uint8Array(buffer);
+console.log(buffer.byteLength); // 1024
+
+// Grow in-place — no new allocation, views stay valid
+buffer.resize(2048);
+console.log(buffer.byteLength); // 2048
+console.log(view.byteLength);   // 2048 — view updated!
+
+// Shrink
+buffer.resize(512);
+
+// Transfer to fixed-length (detaches original)
+const fixed = buffer.transferToFixedLength();
+// buffer is now detached (byteLength === 0)
+// fixed is a regular ArrayBuffer that cannot be resized
+```
+
+### String.isWellFormed() and String.toWellFormed()
+
+```javascript
+// Detect and fix lone surrogates (invalid UTF-16)
+const valid = 'Hello, world!';
+const invalid = 'Hello\uD800World'; // lone surrogate
+
+valid.isWellFormed();   // true
+invalid.isWellFormed(); // false
+
+// Replace lone surrogates with replacement character (U+FFFD)
+invalid.toWellFormed(); // 'Hello\uFFFDWorld'
+
+// Use case: before passing strings to APIs that require valid Unicode
+function safeEncode(str) {
+  return encodeURIComponent(str.isWellFormed() ? str : str.toWellFormed());
+}
+```
+
+---
+
+## ES2025 Features
+
+### Set Methods — Set Algebra
+
+```javascript
+const js = new Set(['react', 'vue', 'angular', 'svelte']);
+const ts = new Set(['react', 'angular', 'solid', 'qwik']);
+
+// union — all elements from both
+js.union(ts);
+// Set { 'react', 'vue', 'angular', 'svelte', 'solid', 'qwik' }
+
+// intersection — elements in both
+js.intersection(ts);
+// Set { 'react', 'angular' }
+
+// difference — in js but not ts
+js.difference(ts);
+// Set { 'vue', 'svelte' }
+
+// symmetricDifference — in either but not both
+js.symmetricDifference(ts);
+// Set { 'vue', 'svelte', 'solid', 'qwik' }
+
+// Membership predicates
+const react = new Set(['react']);
+react.isSubsetOf(js);             // true
+js.isSupersetOf(react);           // true
+react.isDisjointFrom(new Set(['vue', 'svelte'])); // true
+
+// These methods are non-mutating — return new Sets
+```
+
+### Iterator Helpers — Lazy Iteration Protocol
+
+```javascript
+// Iterator helpers are lazy — they don't evaluate until consumed
+// Works on any iterable via Iterator.from()
+
+const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
+
+// map — transform each element
+const doubled = numbers.values().map(x => x * 2).toArray();
+// [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
+
+// filter — keep matching elements
+const evens = numbers.values().filter(x => x % 2 === 0).toArray();
+// [2, 4, 6, 8, 10]
+
+// take — limit to first N
+const first3 = numbers.values().take(3).toArray();
+// [1, 2, 3]
+
+// drop — skip first N
+const after3 = numbers.values().drop(3).toArray();
+// [4, 5, 6, 7, 8, 9, 10]
+
+// flatMap — transform and flatten
+const sentences = ['hello world', 'foo bar'];
+const words = sentences.values()
+  .flatMap(s => s.split(' '))
+  .toArray();
+// ['hello', 'world', 'foo', 'bar']
+
+// reduce — fold
+const sum = numbers.values().reduce((acc, n) => acc + n, 0);
+// 55
+
+// Chaining — lazy, memory-efficient pipeline
+const result = Iterator.from(hugeDataset)
+  .filter(item => item.active)
+  .map(item => transform(item))
+  .take(100)
+  .toArray(); // only evaluates 100 items + filter overhead
+
+// forEach, some, every, find — terminal operations
+numbers.values().some(x => x > 5);  // true
+numbers.values().every(x => x > 0); // true
+numbers.values().find(x => x > 7);  // 8
+```
+
+### Explicit Resource Management — using / Symbol.dispose
+
+```javascript
+// 'using' is like const but automatically calls [Symbol.dispose] on block exit
+// Similar to C# 'using' or Python 'with'
+
+class DatabaseConnection {
+  constructor(url) {
+    this.#conn = openConnection(url);
+    console.log('Connection opened');
+  }
+
+  query(sql) { return this.#conn.query(sql); }
+
+  [Symbol.dispose]() {
+    this.#conn.close();
+    console.log('Connection closed');
+  }
+}
+
+// Automatic cleanup — connection closed even if function throws
+function doWork() {
+  using conn = new DatabaseConnection(DB_URL);
+  const result = conn.query('SELECT * FROM users');
+  return result; // conn[Symbol.dispose]() called here automatically
+}
+
+// await using — for async cleanup
+class FileHandle {
+  static async open(path) {
+    const handle = new FileHandle();
+    handle.#fd = await fs.open(path, 'r');
+    return handle;
+  }
+
+  async read() { return this.#fd.readFile(); }
+
+  async [Symbol.asyncDispose]() {
+    await this.#fd.close();
+  }
+}
+
+async function readFile(path) {
+  await using handle = await FileHandle.open(path);
+  return handle.read(); // handle[Symbol.asyncDispose]() called automatically
+}
+
+// DisposableStack — manage multiple resources
+function processFiles(paths) {
+  using stack = new DisposableStack();
+  const handles = paths.map(p => stack.use(openFile(p)));
+  // All handles disposed when stack goes out of scope
+  return handles.map(h => h.read());
+}
+```
+
+---
+
+## Proxy and Reflect
+
+### Validation Proxy
+
+```javascript
+function createValidated(schema) {
+  return new Proxy({}, {
+    set(target, prop, value) {
+      const validator = schema[prop];
+      if (validator && !validator(value)) {
+        throw new TypeError(`Invalid value for ${String(prop)}: ${value}`);
+      }
+      return Reflect.set(target, prop, value);
+    },
+    get(target, prop) {
+      return Reflect.get(target, prop);
+    },
+  });
+}
+
+const user = createValidated({
+  age: (v) => typeof v === 'number' && v >= 0 && v <= 150,
+  email: (v) => typeof v === 'string' && v.includes('@'),
+});
+
+user.email = 'alice@example.com'; // OK
+user.age = 25;                     // OK
+user.age = -1;                     // TypeError: Invalid value for age: -1
+```
+
+### Observable Proxy — Reactive Data
+
+```javascript
+function observable(target, onChange) {
+  return new Proxy(target, {
+    set(obj, prop, value) {
+      const old = obj[prop];
+      const result = Reflect.set(obj, prop, value);
+      if (old !== value) {
+        onChange(prop, value, old);
+      }
+      return result;
+    },
+    deleteProperty(obj, prop) {
+      const had = prop in obj;
+      const result = Reflect.deleteProperty(obj, prop);
+      if (had) {
+        onChange(prop, undefined, obj[prop]);
+      }
+      return result;
+    },
+  });
+}
+
+const state = observable({ count: 0 }, (key, newVal, oldVal) => {
+  console.log(`${key}: ${oldVal} → ${newVal}`);
+  render();
+});
+
+state.count++;  // count: 0 → 1
+```
+
+### Deep Proxy — Recursive Observable
+
+```javascript
+function deepObservable(target, onChange, path = '') {
+  return new Proxy(target, {
+    get(obj, prop) {
+      const value = Reflect.get(obj, prop);
+      if (value && typeof value === 'object') {
+        // Wrap nested objects
+        return deepObservable(value, onChange, `${path}.${String(prop)}`);
+      }
+      return value;
+    },
+    set(obj, prop, value) {
+      const fullPath = `${path}.${String(prop)}`;
+      const result = Reflect.set(obj, prop, value);
+      onChange(fullPath, value);
+      return result;
+    },
+  });
+}
+```
+
+### Revocable Proxy — Temporary Access
+
+```javascript
+const { proxy, revoke } = Proxy.revocable(sensitiveData, {
+  get(target, prop) {
+    console.log(`Access: ${String(prop)}`);
+    return Reflect.get(target, prop);
+  },
+});
+
+grantAccess(proxy);     // give someone temporary access
+
+setTimeout(() => {
+  revoke();             // all future access throws TypeError
+}, 60_000);
+```
+
+---
+
+## Decorators (Stage 3)
+
+```javascript
+// Method decorator — timing/logging
+function measure(target, context) {
+  return async function(...args) {
+    const start = performance.now();
+    try {
+      return await target.apply(this, args);
+    } finally {
+      console.log(`${context.name} took ${performance.now() - start}ms`);
+    }
+  };
+}
+
+// Field/accessor decorator — validation
+function range(min, max) {
+  return function(target, context) {
+    return {
+      get() { return context.access.get(this); },
+      set(value) {
+        if (value < min || value > max) {
+          throw new RangeError(`${context.name} must be ${min}–${max}`);
+        }
+        context.access.set(this, value);
+      },
+    };
+  };
+}
+
+class UserService {
+  @range(1, 150)
+  accessor age = 0;
+
+  @measure
+  async fetchUser(id) {
+    return this.db.query('SELECT * FROM users WHERE id = ?', [id]);
+  }
+}
+
+// Class decorator — dependency injection / registration
+function injectable(target, context) {
+  target[Symbol.for('injectable')] = true;
+  return target;
+}
+
+@injectable
+class EmailService {
+  send(to, subject, body) { /* ... */ }
+}
+```
+
+---
+
+## Temporal API (Stage 3)
+
+```javascript
+// Modern date/time — replaces the broken Date object
+// Available via polyfill: npm install @js-temporal/polyfill
+
+import { Temporal } from '@js-temporal/polyfill';
+
+// Plain date — no timezone
+const today = Temporal.PlainDate.from('2024-03-15');
+const tomorrow = today.add({ days: 1 });
+const nextMonth = today.add({ months: 1 });
+
+today.toString();  // '2024-03-15'
+
+// Zoned date/time — with timezone
+const nyNow = Temporal.Now.zonedDateTimeISO('America/New_York');
+const tokyoNow = nyNow.withTimeZone('Asia/Tokyo');
+
+// Duration arithmetic
+const meeting = Temporal.ZonedDateTime.from({
+  year: 2024, month: 3, day: 20,
+  hour: 14, minute: 30,
+  timeZone: 'America/Chicago',
+});
+
+const now = Temporal.Now.zonedDateTimeISO('America/Chicago');
+const until = now.until(meeting);
+console.log(`Meeting in ${until.hours}h ${until.minutes}m`);
+
+// Comparison
+const d1 = Temporal.PlainDate.from('2024-01-01');
+const d2 = Temporal.PlainDate.from('2024-06-01');
+Temporal.PlainDate.compare(d1, d2); // -1 (d1 < d2)
+
+// Instant — machine time (like Date.now())
+const start = Temporal.Now.instant();
+await doWork();
+const elapsed = Temporal.Now.instant().since(start);
+console.log(`Took ${elapsed.milliseconds}ms`);
+```
+
+---
+
+## Signals Proposal (Stage 1)
+
+```javascript
+// Reactive primitives — the foundation that Vue, Solid, and Preact Signals built on
+// This is a native proposal; for now use framework-specific implementations
+
+// Framework-specific examples (same concept, different API):
+
+// Preact Signals
+import { signal, computed, effect } from '@preact/signals-core';
+
+const count = signal(0);
+const doubled = computed(() => count.value * 2);
+
+effect(() => {
+  console.log(`count: ${count.value}, doubled: ${doubled.value}`);
+});
+
+count.value = 5; // triggers effect: "count: 5, doubled: 10"
+
+// SolidJS createSignal
+import { createSignal, createMemo, createEffect } from 'solid-js';
+
+const [count, setCount] = createSignal(0);
+const doubled = createMemo(() => count() * 2);
+createEffect(() => console.log(count(), doubled()));
+setCount(5);
+```
+
+---
+
+## Records and Tuples (Stage 2)
+
+```javascript
+// Immutable, value-typed data structures
+// Syntax uses # prefix
+
+// Record — immutable plain object
+const point = #{ x: 1, y: 2 };
+const point2 = #{ x: 1, y: 2 };
+point === point2; // true — compared by VALUE, not reference!
+
+// Tuple — immutable array
+const coords = #[1, 2, 3];
+const coords2 = #[1, 2, 3];
+coords === coords2; // true
+
+// Spread works
+const point3D = #{ ...point, z: 3 };
+
+// Cannot contain mutable objects
+#{ fn: () => {} }; // TypeError — no functions in Records
+#{ obj: {} };      // TypeError — no objects in Records
+// Can contain: primitives, other Records, other Tuples, Symbols
+
+// Use case: React state comparison, Map keys by value
+const map = new Map();
+map.set(#{ x: 1, y: 2 }, 'first quadrant');
+map.get(#{ x: 1, y: 2 }); // 'first quadrant' — value equality!
+```

+ 632 - 0
skills/javascript-ops/references/modules-runtime.md

@@ -0,0 +1,632 @@
+# Modules & Runtime Reference
+
+ESM, CommonJS, dual packages, V8 internals, memory management, and the Node.js event loop in depth.
+
+---
+
+## ESM — ES Modules
+
+### import / export Syntax
+
+```javascript
+// Named exports
+export function add(a, b) { return a + b; }
+export const PI = 3.14159;
+export class Vector { /* ... */ }
+
+// Default export — one per module
+export default class App { /* ... */ }
+
+// Re-export from another module
+export { add, PI } from './math.js';
+export * from './utils.js';                   // re-export all named
+export * as utils from './utils.js';          // re-export as namespace
+export { default as BaseApp } from './base.js'; // re-export default as named
+
+// Named import
+import { add, PI } from './math.js';
+
+// Default import
+import App from './app.js';
+
+// Both default and named
+import App, { version, config } from './app.js';
+
+// Namespace import
+import * as MathUtils from './math.js';
+MathUtils.add(1, 2);
+
+// Rename on import
+import { add as sum, PI as pi } from './math.js';
+```
+
+### import.meta
+
+```javascript
+// import.meta is available in ESM only
+
+// URL of current module (always available)
+console.log(import.meta.url); // file:///path/to/module.mjs
+
+// Derive __dirname equivalent (Node 21.2+ has import.meta.dirname)
+import { fileURLToPath } from 'node:url';
+import { dirname, resolve } from 'node:path';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+// Node 21.2+ — built-in equivalents
+// import.meta.filename → /path/to/module.mjs
+// import.meta.dirname  → /path/to/
+
+// Resolve relative paths to absolute
+const configPath = import.meta.resolve('./config.json');
+// Returns: file:///path/to/config.json
+
+// Environment (Vite, Webpack inject these)
+if (import.meta.env?.DEV) { /* development-only */ }
+if (import.meta.hot) { /* HMR support */ }
+```
+
+---
+
+## Dynamic import()
+
+```javascript
+// Lazy loading — import() returns a Promise<Module>
+const { default: Chart } = await import('./chart.js');
+
+// Conditional platform code
+const platform = process.platform === 'win32'
+  ? await import('./windows.js')
+  : await import('./unix.js');
+
+// Code splitting (works in bundlers and browsers)
+async function loadHeavyFeature() {
+  const { HeavyComponent } = await import('./heavy-component.js');
+  return new HeavyComponent();
+}
+
+// Dynamic path (bundlers may warn — prefer static strings)
+const locale = 'en-US';
+const { messages } = await import(`./locales/${locale}.js`);
+
+// Import attributes (ES2024)
+const config = await import('./config.json', { with: { type: 'json' } });
+const styles = await import('./theme.css', { with: { type: 'css' } });
+
+// import() in CJS modules — interop with ESM
+// In a .cjs file, you can use dynamic import() to load ESM:
+async function loadESMModule() {
+  const esm = await import('./esm-only.mjs');
+  return esm;
+}
+```
+
+---
+
+## CommonJS (CJS)
+
+### require / module.exports
+
+```javascript
+// Synchronous — entire file executes before require() returns
+const path = require('node:path');
+const { readFileSync } = require('node:fs');
+
+// module.exports — single export value
+module.exports = function add(a, b) { return a + b; };
+
+// Attach multiple exports to exports object
+exports.add = (a, b) => a + b;
+exports.PI = 3.14159;
+// Note: never replace exports itself — use module.exports
+
+// __dirname / __filename — built-in in CJS
+console.log(__dirname);  // /path/to/current/directory
+console.log(__filename); // /path/to/current/file.js
+
+// require.resolve — get full path without loading
+const configPath = require.resolve('./config');
+
+// require.cache — access loaded module cache
+delete require.cache[require.resolve('./module')]; // force re-load
+
+// Conditional require for optional dependencies
+let chalk;
+try {
+  chalk = require('chalk');
+} catch {
+  chalk = { red: s => s, green: s => s }; // fallback
+}
+```
+
+### CJS ↔ ESM Interop
+
+```javascript
+// From CJS: load ESM with dynamic import (async!)
+// require() cannot load ES modules directly
+const esmModule = await import('./esm-module.mjs');
+
+// From ESM: load CJS with static import (works!)
+import cjsModule from './commonjs-module.cjs';
+// Named exports from CJS — only default is guaranteed
+// Some bundlers analyze CJS for named exports (Vite, Rollup)
+
+// util.promisify — convert CJS callbacks to Promises
+import { promisify } from 'node:util';
+import { readFile } from 'node:fs';
+const readFileAsync = promisify(readFile);
+const content = await readFileAsync('./file.txt', 'utf8');
+```
+
+---
+
+## Dual Packages — Publish ESM + CJS
+
+### package.json "exports" Field
+
+```json
+{
+  "name": "my-library",
+  "version": "1.0.0",
+  "type": "module",
+  "main": "./dist/index.cjs",
+  "module": "./dist/index.mjs",
+  "exports": {
+    ".": {
+      "import": {
+        "types": "./dist/index.d.mts",
+        "default": "./dist/index.mjs"
+      },
+      "require": {
+        "types": "./dist/index.d.cts",
+        "default": "./dist/index.cjs"
+      }
+    },
+    "./utils": {
+      "import": "./dist/utils.mjs",
+      "require": "./dist/utils.cjs"
+    },
+    "./package.json": "./package.json"
+  },
+  "files": ["dist"]
+}
+```
+
+### Conditional Exports — Environment-Specific
+
+```json
+{
+  "exports": {
+    ".": {
+      "browser": "./dist/browser.mjs",
+      "worker": "./dist/worker.mjs",
+      "node": {
+        "import": "./dist/node.mjs",
+        "require": "./dist/node.cjs"
+      },
+      "default": "./dist/index.mjs"
+    }
+  }
+}
+```
+
+### Dual Package Hazard
+
+When both ESM and CJS versions are loaded in the same process, singletons (class instances, global state) can be duplicated. Guard with state stored outside module scope:
+
+```javascript
+// Use a package-level state file or Symbol registry to detect duplication
+// package: ./src/state.mjs
+let instance = null;
+export function getInstance() {
+  if (!instance) instance = createInstance();
+  return instance;
+}
+```
+
+---
+
+## Tree Shaking
+
+```json
+// package.json — mark package as free of side effects
+{
+  "sideEffects": false
+}
+
+// Or list files with side effects
+{
+  "sideEffects": [
+    "*.css",
+    "./src/polyfills.js",
+    "./src/global-setup.js"
+  ]
+}
+```
+
+```javascript
+// Barrel file pitfall — re-exporting everything kills tree shaking
+// BAD: src/index.js
+export * from './moduleA'; // bundler must include ALL of moduleA
+export * from './moduleB';
+export * from './moduleC';
+
+// GOOD: import directly from source
+import { specificThing } from './lib/moduleA';
+
+// GOOD: barrel with explicit named exports is better
+export { ThingA, ThingB } from './moduleA';  // explicit = tree-shakeable
+
+// Side-effect-free module pattern
+// Don't do top-level work that modifies globals
+// BAD:
+Array.prototype.sum = function() { return this.reduce((a, b) => a + b, 0); };
+
+// GOOD:
+export function sum(arr) { return arr.reduce((a, b) => a + b, 0); }
+```
+
+---
+
+## Bundler Comparison — Module Handling
+
+| Feature | Vite | esbuild | Rollup | Webpack 5 |
+|---------|------|---------|--------|-----------|
+| Default output | ESM + CJS | ESM/CJS/IIFE | ESM + CJS | CJS/ESM |
+| Tree shaking | Yes (Rollup) | Yes | Yes | Yes |
+| Code splitting | Yes | Yes | Yes | Yes |
+| CJS named imports | Analyzed | Analyzed | Analyzed | Analyzed |
+| `sideEffects` respected | Yes | Yes | Yes | Yes |
+| Top-level await | Yes | Yes | Yes | Partial |
+| Import attributes | Planned | No | Plugin | Loader |
+| Speed | Fast (esbuild) | Fastest | Moderate | Slow |
+
+```javascript
+// Vite — resolves modules with node resolution + browser overrides
+// vite.config.js
+export default {
+  build: {
+    lib: {
+      entry: './src/index.ts',
+      formats: ['es', 'cjs'],
+      fileName: (format) => `my-lib.${format === 'es' ? 'mjs' : 'cjs'}`,
+    },
+    rollupOptions: {
+      external: ['react', 'vue'], // don't bundle peer deps
+    },
+  },
+};
+```
+
+---
+
+## V8 Internals — Writing Optimizable Code
+
+### Hidden Classes (Shapes)
+
+```javascript
+// V8 creates a "hidden class" (shape) for each object structure
+// Objects with the same properties in the same ORDER share a shape
+
+// GOOD — consistent shape
+function Point(x, y) {
+  this.x = x; // always in this order
+  this.y = y;
+}
+const p1 = new Point(1, 2); // shape: { x, y }
+const p2 = new Point(3, 4); // same shape — fast!
+
+// BAD — dynamic property addition changes shape
+const obj = {};
+obj.x = 1;   // shape 1: { x }
+obj.y = 2;   // shape 2: { x, y } — shape transition!
+
+// BAD — adding properties in different orders
+function makePoint(x, y, swap) {
+  const p = {};
+  if (swap) { p.y = y; p.x = x; } // different order!
+  else { p.x = x; p.y = y; }
+  return p;
+}
+// p.x may hit a different shape → slower property access
+```
+
+### Inline Caches (ICs)
+
+```javascript
+// V8 caches property lookup results at each call site
+// Monomorphic (1 shape) → fast
+// Polymorphic (2-4 shapes) → slower
+// Megamorphic (5+ shapes) → very slow, no caching
+
+// GOOD — function always receives same shape
+function area(rect) {
+  return rect.width * rect.height; // monomorphic — one shape
+}
+area({ width: 10, height: 20 });
+area({ width: 5, height: 15 });
+
+// BAD — function receives many different shapes
+function getProperty(obj, key) {
+  return obj[key]; // megamorphic — every object is different
+}
+```
+
+### JIT Compilation — What Deoptimizes
+
+```javascript
+// Things that prevent or undo JIT optimization:
+
+// 1. typeof checks can hint at types — use them consistently
+// 2. delete operator changes object shape
+delete obj.property; // BAD for perf — sets to undefined instead
+
+// 3. arguments object prevents optimization
+function sum() {
+  let total = 0;
+  for (let i = 0; i < arguments.length; i++) total += arguments[i]; // slow
+  return total;
+}
+// GOOD: use rest parameters
+function sum(...nums) {
+  return nums.reduce((a, b) => a + b, 0);
+}
+
+// 4. Changing array element types
+const arr = [1, 2, 3];       // SMI (small integer) array — fastest
+arr.push(1.5);               // now DOUBLE array — shape changed
+arr.push('hello');           // now ELEMENTS array — slowest
+
+// 5. try/catch in hot loops (older V8; mostly fixed in Node 12+)
+// Still worth moving try/catch outside tight loops when possible
+```
+
+---
+
+## Memory Management
+
+### Garbage Collection — Mark-and-Sweep
+
+```javascript
+// V8 uses generational GC:
+// Young generation (Scavenger) — short-lived objects, collected frequently, fast
+// Old generation (Mark-Sweep-Compact) — survived 2 young GCs, collected less often
+
+// Objects become unreachable when no references point to them
+function createLeak() {
+  const largeData = new Array(1_000_000).fill(0);
+  // If largeData is captured by a closure that outlives this function...
+  globalThis.leakedCallback = () => largeData.length; // LEAK!
+}
+
+// GOOD: explicitly null out large references
+globalThis.leakedCallback = null;
+largeData = null;
+```
+
+### WeakRef — Weak References
+
+```javascript
+// WeakRef allows GC to collect the object even if the ref exists
+class ImageCache {
+  #cache = new Map();
+
+  set(key, image) {
+    this.#cache.set(key, new WeakRef(image));
+  }
+
+  get(key) {
+    const ref = this.#cache.get(key);
+    if (!ref) return null;
+    const image = ref.deref(); // returns undefined if GC'd
+    if (!image) {
+      this.#cache.delete(key); // clean up dead entry
+      return null;
+    }
+    return image;
+  }
+}
+```
+
+### FinalizationRegistry — Cleanup After GC
+
+```javascript
+// Runs a callback AFTER an object is garbage collected
+const registry = new FinalizationRegistry((heldValue) => {
+  console.log(`Object with key ${heldValue} was collected`);
+  cleanupResource(heldValue);
+});
+
+function createResource(key) {
+  const resource = new SomeResource();
+  registry.register(resource, key); // register for cleanup notification
+  return resource;
+}
+
+// Caution: cleanup callback runs in unpredictable timing
+// Do NOT use for time-sensitive cleanup — use explicit disposal instead
+```
+
+### Common Memory Leaks
+
+```javascript
+// 1. Forgotten event listeners
+const el = document.querySelector('#button');
+el.addEventListener('click', handler); // LEAK if el is removed from DOM
+
+// FIX: remove listener when no longer needed
+el.removeEventListener('click', handler);
+// OR: use { once: true } for one-time listeners
+el.addEventListener('click', handler, { once: true });
+// OR: use AbortController to remove multiple listeners at once
+const ac = new AbortController();
+el.addEventListener('click', handler, { signal: ac.signal });
+el.addEventListener('focus', handler2, { signal: ac.signal });
+ac.abort(); // removes all listeners at once
+
+// 2. Timers holding references
+const data = fetchLargeData();
+const timer = setInterval(() => {
+  process(data); // data is kept alive by closure
+}, 1000);
+// FIX:
+clearInterval(timer);
+
+// 3. Closures capturing large scope
+function setup() {
+  const HUGE_ARRAY = new Array(1_000_000);
+  return function small() {
+    return HUGE_ARRAY.length; // keeps HUGE_ARRAY alive!
+  };
+}
+
+// FIX: only capture what you need
+function setup() {
+  const HUGE_ARRAY = new Array(1_000_000);
+  const size = HUGE_ARRAY.length; // extract the value
+  return function small() {
+    return size; // HUGE_ARRAY can now be collected
+  };
+}
+
+// 4. Detached DOM nodes
+let el = document.querySelector('#container');
+const cache = new WeakMap(); // WeakMap — keys are weakly held
+cache.set(el, { data: 'important' });
+el = null; // but if the DOM node is detached and nobody holds it, WeakMap auto-cleans
+
+// 5. Growing arrays/maps without eviction
+class EventBus {
+  #handlers = new Map(); // grows forever if subscribers never unsubscribe!
+
+  on(event, handler) { /* ... */ }
+  off(event, handler) { /* ... */ } // MUST provide this
+}
+```
+
+---
+
+## Event Loop Deep Dive (Node.js)
+
+### Libuv Phases
+
+```
+Each "tick" of the Node.js event loop runs these phases in order:
+
+┌──────────────────────────────────────────────────────┐
+│  timers                                              │
+│  Executes setTimeout() and setInterval() callbacks  │
+│  (after their minimum delay — not exactly)          │
+└──────────────────────┬───────────────────────────────┘
+                       │ process.nextTick + microtasks drain here
+┌──────────────────────▼───────────────────────────────┐
+│  pending callbacks                                   │
+│  I/O callbacks deferred from previous iteration     │
+│  (e.g., TCP errors)                                 │
+└──────────────────────┬───────────────────────────────┘
+                       │ process.nextTick + microtasks drain here
+┌──────────────────────▼───────────────────────────────┐
+│  idle, prepare                                       │
+│  Internal use only                                  │
+└──────────────────────┬───────────────────────────────┘
+┌──────────────────────▼───────────────────────────────┐
+│  poll                                                │
+│  Retrieve new I/O events — execute I/O callbacks    │
+│  (will block here if nothing pending)               │
+└──────────────────────┬───────────────────────────────┘
+                       │ process.nextTick + microtasks drain here
+┌──────────────────────▼───────────────────────────────┐
+│  check                                               │
+│  setImmediate() callbacks execute here              │
+└──────────────────────┬───────────────────────────────┘
+                       │ process.nextTick + microtasks drain here
+┌──────────────────────▼───────────────────────────────┐
+│  close callbacks                                     │
+│  e.g., socket.on('close', ...) callbacks            │
+└──────────────────────────────────────────────────────┘
+```
+
+### process.nextTick vs setImmediate vs queueMicrotask
+
+```javascript
+console.log('1: sync start');
+
+setTimeout(() => console.log('5: setTimeout'), 0);
+
+setImmediate(() => console.log('6: setImmediate'));
+
+Promise.resolve().then(() => console.log('3: Promise.then (microtask)'));
+
+queueMicrotask(() => console.log('4: queueMicrotask'));
+
+process.nextTick(() => console.log('2: nextTick'));
+
+console.log('1b: sync end');
+
+// Output order:
+// 1: sync start
+// 1b: sync end
+// 2: nextTick           ← nextTick runs before other microtasks
+// 3: Promise.then       ← then other microtasks
+// 4: queueMicrotask     ← queueMicrotask is a microtask
+// 5: setTimeout         ← macrotask (timers phase)
+// 6: setImmediate       ← macrotask (check phase)
+
+// NOTE: setTimeout vs setImmediate order is non-deterministic
+// UNLESS inside an I/O callback — then setImmediate always comes first
+fs.readFile('./file', () => {
+  setTimeout(() => console.log('timeout'), 0);
+  setImmediate(() => console.log('immediate')); // always first in I/O callback
+});
+```
+
+### process.nextTick — Use Sparingly
+
+```javascript
+// nextTick runs BEFORE I/O, even before Promises
+// Recursive nextTick can starve I/O (starvation attack)
+
+// BAD: recursive nextTick starves event loop
+function badRecursion() {
+  process.nextTick(badRecursion); // I/O NEVER runs!
+}
+
+// GOOD for: async-like callback for sync operations
+class EventEmitter {
+  emit(event, data) {
+    // Emit in next tick to allow current stack to finish
+    process.nextTick(() => {
+      this.handlers.get(event)?.forEach(h => h(data));
+    });
+  }
+}
+
+// PREFER: queueMicrotask (same timing, no nextTick starvation risk)
+queueMicrotask(() => doSomething());
+```
+
+---
+
+## Import Attributes (ES2024)
+
+```javascript
+// Static import with type assertion
+import data from './data.json' with { type: 'json' };
+import styles from './theme.css' with { type: 'css' };
+import wasm from './module.wasm' with { type: 'webassembly' };
+
+// Dynamic import with attributes
+const config = await import('./config.json', { with: { type: 'json' } });
+
+// Node.js — JSON modules require import assertion
+import pkg from './package.json' with { type: 'json' };
+console.log(pkg.version);
+
+// Bundler support:
+// Vite: supported for JSON (built-in) and CSS
+// esbuild: JSON supported
+// Rollup: JSON plugin
+// Webpack: asset modules
+```

+ 1036 - 0
skills/javascript-ops/references/node-patterns.md

@@ -0,0 +1,1036 @@
+# Node.js Patterns Reference
+
+Production-ready Node.js patterns — testing, file system, workers, streams, crypto, and operational concerns.
+
+---
+
+## Built-In Test Runner (node:test)
+
+Available from Node 18 (experimental) and Node 20+ (stable).
+
+### Basic Structure
+
+```javascript
+// test/user.test.mjs
+import { describe, it, before, after, beforeEach, afterEach } from 'node:test';
+import assert from 'node:assert/strict';
+import { createUser, getUser, deleteUser } from '../src/user.js';
+
+describe('User module', () => {
+  let userId;
+
+  before(async () => {
+    // Setup — runs once before all tests in this describe block
+    await db.connect();
+  });
+
+  after(async () => {
+    // Teardown — runs once after all tests
+    await db.disconnect();
+  });
+
+  beforeEach(async () => {
+    // Runs before each test
+    const user = await createUser({ name: 'Alice', email: 'alice@example.com' });
+    userId = user.id;
+  });
+
+  afterEach(async () => {
+    // Runs after each test
+    await deleteUser(userId).catch(() => {}); // ignore if already deleted
+  });
+
+  it('creates a user', async () => {
+    const user = await getUser(userId);
+    assert.equal(user.name, 'Alice');
+    assert.equal(user.email, 'alice@example.com');
+    assert.ok(user.id, 'should have an id');
+  });
+
+  it('throws on missing user', async () => {
+    await assert.rejects(
+      () => getUser('nonexistent-id'),
+      { name: 'NotFoundError' }
+    );
+  });
+});
+```
+
+### Running Tests
+
+```bash
+# Run all test files
+node --test
+
+# Specific files or glob
+node --test test/**/*.test.mjs
+
+# Watch mode (re-runs on file change)
+node --test --watch
+
+# With coverage (experimental)
+node --test --experimental-test-coverage
+
+# Filter by test name
+node --test --test-name-pattern="User module"
+
+# Concurrency (default: os.availableParallelism() - 1)
+node --test --test-concurrency=4
+
+# Reporter (spec, tap, dot, junit)
+node --test --test-reporter=spec
+node --test --test-reporter=junit --test-reporter-destination=results.xml
+```
+
+### Mocking
+
+```javascript
+import { describe, it, mock } from 'node:test';
+import assert from 'node:assert/strict';
+
+// Mock a function
+const fn = mock.fn((x) => x * 2);
+fn(5);
+fn(10);
+
+assert.equal(fn.mock.calls.length, 2);
+assert.deepEqual(fn.mock.calls[0].arguments, [5]);
+assert.equal(fn.mock.calls[0].result, 10);
+
+// Reset mock state
+fn.mock.resetCalls();
+
+// Restore mocked methods
+fn.mock.restore();
+```
+
+```javascript
+// Mock module methods
+import { describe, it, mock, afterEach } from 'node:test';
+import assert from 'node:assert/strict';
+import * as fs from 'node:fs/promises';
+
+describe('config loader', () => {
+  afterEach(() => mock.restoreAll()); // important — restore after each test
+
+  it('loads config from file', async () => {
+    // Replace fs.readFile with a mock
+    mock.method(fs, 'readFile', async () => '{"port": 3000}');
+
+    const config = await loadConfig('./config.json');
+    assert.equal(config.port, 3000);
+  });
+
+  it('uses default when file missing', async () => {
+    mock.method(fs, 'readFile', async () => {
+      throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
+    });
+
+    const config = await loadConfig('./config.json');
+    assert.deepEqual(config, { port: 8080 }); // default
+  });
+});
+```
+
+### Timer Mocking
+
+```javascript
+import { describe, it, mock } from 'node:test';
+import assert from 'node:assert/strict';
+
+describe('debounce', () => {
+  it('delays execution', async () => {
+    // Take control of timers
+    mock.timers.enable(['setTimeout', 'setInterval']);
+
+    const fn = mock.fn();
+    const debounced = debounce(fn, 1000);
+
+    debounced();
+    debounced();
+    debounced();
+
+    assert.equal(fn.mock.calls.length, 0); // not called yet
+
+    // Advance time by 1000ms
+    mock.timers.tick(1000);
+
+    assert.equal(fn.mock.calls.length, 1); // called once (debounced)
+
+    mock.timers.reset();
+  });
+});
+```
+
+### Snapshot Testing
+
+```javascript
+import { describe, it } from 'node:test';
+
+describe('serializer', () => {
+  it('formats output correctly', (t) => {
+    const output = serialize({ name: 'Alice', scores: [1, 2, 3] });
+    t.assert.snapshot(output);
+    // First run: creates .snap file
+    // Subsequent runs: compares against snapshot
+  });
+});
+
+// Update snapshots:
+// node --test --test-update-snapshots
+```
+
+---
+
+## File System — fs/promises
+
+### Common Operations
+
+```javascript
+import {
+  readFile, writeFile, appendFile,
+  readdir, mkdir, rm, rename, copyFile,
+  stat, access, watch,
+  open, // FileHandle for streaming
+} from 'node:fs/promises';
+import { constants } from 'node:fs';
+
+// Read file
+const content = await readFile('./config.json', 'utf8');
+const data = JSON.parse(content);
+
+// Write file (creates or overwrites)
+await writeFile('./output.json', JSON.stringify(data, null, 2), 'utf8');
+
+// Append
+await appendFile('./log.txt', `${new Date().toISOString()} — event\n`);
+
+// Check if file exists
+try {
+  await access('./config.json', constants.F_OK);
+  console.log('File exists');
+} catch {
+  console.log('File does not exist');
+}
+
+// Stat — file metadata
+const stats = await stat('./config.json');
+stats.isFile();       // true
+stats.isDirectory();  // false
+stats.size;           // bytes
+stats.mtime;          // last modified Date
+
+// Read directory
+const entries = await readdir('./src', { withFileTypes: true });
+const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
+const files = entries.filter(e => e.isFile()).map(e => e.name);
+
+// Recursive readdir (Node 18.17+)
+const allFiles = await readdir('./src', { recursive: true });
+
+// Create directory (recursive creates intermediate dirs)
+await mkdir('./dist/assets/images', { recursive: true });
+
+// Delete — rm replaces deprecated rmdir
+await rm('./dist', { recursive: true, force: true }); // rm -rf
+
+// Rename / move
+await rename('./old-name.js', './new-name.js');
+
+// Copy
+await copyFile('./src/file.js', './dist/file.js');
+```
+
+### Glob (Node 22+)
+
+```javascript
+// Native glob — no glob package needed in Node 22+
+import { glob } from 'node:fs/promises';
+
+const tsFiles = await Array.fromAsync(glob('**/*.ts', { cwd: './src' }));
+const testFiles = await Array.fromAsync(glob('**/*.test.{js,mjs,ts}'));
+```
+
+### File Watching
+
+```javascript
+// Watch a file or directory for changes
+const watcher = watch('./src', { recursive: true });
+
+for await (const event of watcher) {
+  console.log(event.eventType, event.filename);
+  // event.eventType: 'rename' | 'change'
+  // event.filename: relative path from watch target
+}
+
+// Stop watching
+watcher.close();
+// OR: use AbortSignal
+const ac = new AbortController();
+const watcher2 = watch('./config.json', { signal: ac.signal });
+for await (const event of watcher2) {
+  if (shouldStop) ac.abort();
+  await reloadConfig();
+}
+```
+
+### Streaming File Operations
+
+```javascript
+import { open } from 'node:fs/promises';
+
+// FileHandle for large files — streaming read
+async function processLargeFile(path) {
+  const handle = await open(path, 'r');
+  try {
+    const stream = handle.createReadStream({ encoding: 'utf8' });
+    for await (const chunk of stream) {
+      await processChunk(chunk);
+    }
+  } finally {
+    await handle.close(); // always close
+  }
+}
+
+// With 'using' (Node 22+ / ES2025)
+async function processWithUsing(path) {
+  await using handle = await open(path, 'r'); // auto-closes
+  for await (const chunk of handle.createReadStream()) {
+    await processChunk(chunk);
+  }
+}
+```
+
+---
+
+## Worker Threads
+
+```javascript
+// worker_threads — true parallelism, shared memory
+import { Worker, isMainThread, parentPort, workerData,
+         receiveMessageOnPort, MessageChannel } from 'node:worker_threads';
+import { cpus } from 'node:os';
+```
+
+### Main Thread
+
+```javascript
+// main.mjs
+import { Worker } from 'node:worker_threads';
+
+function runWorker(data) {
+  return new Promise((resolve, reject) => {
+    const worker = new Worker('./worker.mjs', { workerData: data });
+
+    worker.on('message', resolve);
+    worker.on('error', reject);
+    worker.on('exit', (code) => {
+      if (code !== 0) reject(new Error(`Worker exited with code ${code}`));
+    });
+  });
+}
+
+// Parallel CPU work across all cores
+const cpuCount = cpus().length;
+const chunks = splitData(data, cpuCount);
+const results = await Promise.all(chunks.map(chunk => runWorker(chunk)));
+const final = mergeResults(results);
+```
+
+### Worker Thread
+
+```javascript
+// worker.mjs
+import { parentPort, workerData } from 'node:worker_threads';
+
+// workerData is a deep clone of what was passed to Worker constructor
+const result = heavyComputation(workerData);
+
+// Send result back to main thread
+parentPort.postMessage(result);
+```
+
+### Thread Pool Pattern
+
+```javascript
+// Reusable worker pool — avoid create/destroy overhead
+class WorkerPool {
+  #workers = [];
+  #queue = [];
+  #size;
+
+  constructor(workerPath, size = cpus().length) {
+    this.#size = size;
+    this.#workers = Array.from({ length: size }, () => ({
+      worker: new Worker(workerPath),
+      busy: false,
+    }));
+
+    for (const entry of this.#workers) {
+      entry.worker.on('message', (result) => {
+        entry.busy = false;
+        entry.resolve(result);
+        this.#processQueue();
+      });
+    }
+  }
+
+  run(data) {
+    return new Promise((resolve, reject) => {
+      this.#queue.push({ data, resolve, reject });
+      this.#processQueue();
+    });
+  }
+
+  #processQueue() {
+    if (!this.#queue.length) return;
+    const idle = this.#workers.find(w => !w.busy);
+    if (!idle) return;
+
+    const { data, resolve, reject } = this.#queue.shift();
+    idle.busy = true;
+    idle.resolve = resolve;
+    idle.reject = reject;
+    idle.worker.postMessage(data);
+  }
+
+  async terminate() {
+    await Promise.all(this.#workers.map(({ worker }) => worker.terminate()));
+  }
+}
+
+const pool = new WorkerPool('./image-processor.mjs', 4);
+const thumbnails = await Promise.all(
+  images.map(img => pool.run({ image: img, width: 200 }))
+);
+await pool.terminate();
+```
+
+### SharedArrayBuffer in Node.js
+
+```javascript
+// main.mjs
+import { Worker } from 'node:worker_threads';
+
+const shared = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 1024);
+const counter = new Int32Array(shared);
+
+const workers = Array.from({ length: 4 }, () =>
+  new Worker('./counter-worker.mjs', {
+    workerData: { shared }, // no transfer needed — it's shared
+  })
+);
+
+await Promise.all(workers.map(w => new Promise(r => w.on('exit', r))));
+console.log('Final count:', Atomics.load(counter, 0));
+```
+
+```javascript
+// counter-worker.mjs
+import { workerData } from 'node:worker_threads';
+
+const counter = new Int32Array(workerData.shared);
+for (let i = 0; i < 10_000; i++) {
+  Atomics.add(counter, 0, 1); // atomic — no race condition
+}
+```
+
+---
+
+## Cluster Module
+
+```javascript
+import cluster from 'node:cluster';
+import { cpus } from 'node:os';
+import { createServer } from 'node:http';
+
+if (cluster.isPrimary) {
+  // Fork one worker per CPU
+  const numCPUs = cpus().length;
+  for (let i = 0; i < numCPUs; i++) {
+    cluster.fork();
+  }
+
+  cluster.on('exit', (worker, code, signal) => {
+    console.log(`Worker ${worker.process.pid} died (${signal || code})`);
+    cluster.fork(); // auto-restart
+  });
+
+  // Graceful restart — zero-downtime deploy
+  process.on('SIGUSR2', () => {
+    const workers = Object.values(cluster.workers);
+    let i = 0;
+    function restartNext() {
+      if (i >= workers.length) return;
+      const worker = workers[i++];
+      worker.once('exit', () => {
+        cluster.fork().once('listening', restartNext);
+      });
+      worker.kill('SIGTERM');
+    }
+    restartNext();
+  });
+} else {
+  // Worker process
+  createServer((req, res) => {
+    res.writeHead(200);
+    res.end(`Worker ${process.pid}: hello\n`);
+  }).listen(3000);
+
+  // Graceful shutdown signal from primary
+  process.on('SIGTERM', () => {
+    server.close(() => process.exit(0));
+  });
+}
+```
+
+---
+
+## Streams
+
+### node:stream with pipeline()
+
+```javascript
+import { pipeline, Transform, Readable, Writable } from 'node:stream';
+import { promisify } from 'node:util';
+import { createReadStream, createWriteStream } from 'node:fs';
+import { createGzip, createGunzip } from 'node:zlib';
+
+const pipelineAsync = promisify(pipeline);
+// OR: import { pipeline } from 'node:stream/promises';
+
+// Compress a file
+await pipelineAsync(
+  createReadStream('./large-file.txt'),
+  createGzip(),
+  createWriteStream('./large-file.txt.gz'),
+);
+
+// Custom Transform stream
+class CSVParser extends Transform {
+  #buffer = '';
+  #headers = null;
+
+  constructor() {
+    super({ readableObjectMode: true }); // output objects
+  }
+
+  _transform(chunk, encoding, callback) {
+    this.#buffer += chunk.toString();
+    const lines = this.#buffer.split('\n');
+    this.#buffer = lines.pop(); // keep incomplete last line
+
+    for (const line of lines) {
+      if (!this.#headers) {
+        this.#headers = line.split(',').map(h => h.trim());
+      } else {
+        const values = line.split(',');
+        const record = Object.fromEntries(
+          this.#headers.map((h, i) => [h, values[i]?.trim()])
+        );
+        this.push(record);
+      }
+    }
+    callback();
+  }
+
+  _flush(callback) {
+    if (this.#buffer.trim() && this.#headers) {
+      const values = this.#buffer.split(',');
+      this.push(Object.fromEntries(
+        this.#headers.map((h, i) => [h, values[i]?.trim()])
+      ));
+    }
+    callback();
+  }
+}
+
+// Process a large CSV without loading it all into memory
+await pipeline(
+  createReadStream('./data.csv'),
+  new CSVParser(),
+  new Writable({
+    objectMode: true,
+    async write(record, encoding, callback) {
+      try {
+        await db.insert('records', record);
+        callback();
+      } catch (err) {
+        callback(err);
+      }
+    },
+  }),
+);
+```
+
+### Web Streams Interop (Node 18+)
+
+```javascript
+import { Readable } from 'node:stream';
+
+// Convert Node stream to Web ReadableStream
+const nodeReadable = createReadStream('./file.txt');
+const webStream = Readable.toWeb(nodeReadable);
+
+// Convert Web ReadableStream to Node stream
+const nodeFromWeb = Readable.fromWeb(webStream);
+
+// Use web streams with fetch response body
+const response = await fetch('https://api.example.com/large');
+for await (const chunk of response.body) { // response.body is ReadableStream
+  await processChunk(chunk);
+}
+```
+
+---
+
+## HTTP
+
+### node:http Server
+
+```javascript
+import { createServer } from 'node:http';
+
+const server = createServer(async (req, res) => {
+  const url = new URL(req.url, `http://${req.headers.host}`);
+
+  if (url.pathname === '/health') {
+    res.writeHead(200, { 'Content-Type': 'application/json' });
+    res.end(JSON.stringify({ status: 'ok', uptime: process.uptime() }));
+    return;
+  }
+
+  if (req.method === 'POST' && url.pathname === '/api/data') {
+    // Read request body
+    const chunks = [];
+    for await (const chunk of req) chunks.push(chunk);
+    const body = JSON.parse(Buffer.concat(chunks).toString());
+
+    res.writeHead(200, { 'Content-Type': 'application/json' });
+    res.end(JSON.stringify({ received: body }));
+    return;
+  }
+
+  res.writeHead(404);
+  res.end('Not found');
+});
+
+server.listen(3000, () => console.log('Server on :3000'));
+```
+
+### undici — Modern HTTP Client (Node built-in)
+
+```javascript
+// undici is bundled with Node 18+ — faster than node-fetch
+import { fetch, request, stream, pipeline } from 'undici';
+
+// Standard fetch (same as global fetch in Node 18+)
+const res = await fetch('https://api.example.com/users');
+const users = await res.json();
+
+// Low-level request with connection pooling
+const { statusCode, headers, body } = await request('https://api.example.com/data', {
+  method: 'POST',
+  headers: { 'Content-Type': 'application/json' },
+  body: JSON.stringify({ key: 'value' }),
+});
+const data = await body.json();
+
+// Stream response directly to file
+import { createWriteStream } from 'node:fs';
+await stream('https://example.com/large-file.zip',
+  { method: 'GET' },
+  () => createWriteStream('./download.zip')
+);
+```
+
+---
+
+## Crypto
+
+```javascript
+import {
+  randomUUID, randomBytes, randomInt,
+  createHash, createHmac,
+  createCipheriv, createDecipheriv,
+  scrypt, scryptSync,
+  timingSafeEqual,
+  generateKeyPair, createSign, createVerify,
+} from 'node:crypto';
+import { promisify } from 'node:util';
+
+const scryptAsync = promisify(scrypt);
+
+// Unique IDs
+const id = randomUUID(); // cryptographically random UUID v4
+const token = randomBytes(32).toString('hex'); // 64-char hex token
+const otp = randomInt(100_000, 999_999); // 6-digit OTP
+
+// Hashing
+const hash = createHash('sha256').update('content').digest('hex');
+
+// HMAC
+const hmac = createHmac('sha256', 'secret-key').update('message').digest('hex');
+
+// Password hashing with scrypt
+async function hashPassword(password) {
+  const salt = randomBytes(16);
+  const hash = await scryptAsync(password, salt, 64);
+  return `${salt.toString('hex')}:${hash.toString('hex')}`;
+}
+
+async function verifyPassword(password, stored) {
+  const [saltHex, hashHex] = stored.split(':');
+  const salt = Buffer.from(saltHex, 'hex');
+  const hash = await scryptAsync(password, salt, 64);
+  // timingSafeEqual prevents timing attacks
+  return timingSafeEqual(hash, Buffer.from(hashHex, 'hex'));
+}
+
+// AES-256-GCM encryption
+function encrypt(plaintext, keyHex) {
+  const key = Buffer.from(keyHex, 'hex'); // 32 bytes for AES-256
+  const iv = randomBytes(16);
+  const cipher = createCipheriv('aes-256-gcm', key, iv);
+
+  const encrypted = Buffer.concat([
+    cipher.update(plaintext, 'utf8'),
+    cipher.final(),
+  ]);
+  const authTag = cipher.getAuthTag();
+
+  return {
+    iv: iv.toString('hex'),
+    encrypted: encrypted.toString('hex'),
+    authTag: authTag.toString('hex'),
+  };
+}
+
+function decrypt({ iv, encrypted, authTag }, keyHex) {
+  const key = Buffer.from(keyHex, 'hex');
+  const decipher = createDecipheriv(
+    'aes-256-gcm',
+    key,
+    Buffer.from(iv, 'hex')
+  );
+  decipher.setAuthTag(Buffer.from(authTag, 'hex'));
+
+  return Buffer.concat([
+    decipher.update(Buffer.from(encrypted, 'hex')),
+    decipher.final(),
+  ]).toString('utf8');
+}
+
+// RSA signing
+const { privateKey, publicKey } = await promisify(generateKeyPair)('rsa', {
+  modulusLength: 2048,
+});
+
+const sign = createSign('SHA256');
+sign.update('message to sign');
+const signature = sign.sign(privateKey, 'hex');
+
+const verify = createVerify('SHA256');
+verify.update('message to sign');
+const isValid = verify.verify(publicKey, signature, 'hex');
+```
+
+---
+
+## Diagnostics and Observability
+
+### diagnostics_channel
+
+```javascript
+import diagnostics from 'node:diagnostics_channel';
+
+// Publisher — library code
+const ch = diagnostics.channel('mylib:db:query');
+
+async function query(sql, params) {
+  if (ch.hasSubscribers) {
+    ch.publish({ sql, params, start: performance.now() });
+  }
+  const result = await db.execute(sql, params);
+  if (ch.hasSubscribers) {
+    ch.publish({ sql, params, duration: performance.now() - start, rows: result.rowCount });
+  }
+  return result;
+}
+
+// Subscriber — monitoring/APM code
+diagnostics.channel('mylib:db:query').subscribe((data) => {
+  metrics.histogram('db.query.duration', data.duration, { sql: data.sql });
+});
+
+// Subscribe to undici (built-in HTTP client) events
+diagnostics.channel('undici:request:create').subscribe((data) => {
+  console.log('HTTP request:', data.request.method, data.request.origin + data.request.path);
+});
+```
+
+### Performance Hooks
+
+```javascript
+import { performance, PerformanceObserver } from 'node:perf_hooks';
+
+// Mark + measure
+performance.mark('start');
+await doWork();
+performance.mark('end');
+performance.measure('doWork', 'start', 'end');
+
+const entries = performance.getEntriesByName('doWork');
+console.log(`Duration: ${entries[0].duration}ms`);
+
+// Observe all measures
+const obs = new PerformanceObserver((list) => {
+  for (const entry of list.getEntries()) {
+    console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`);
+  }
+});
+obs.observe({ entryTypes: ['measure'] });
+
+// timerify — auto-measure function calls
+function myFunction() { /* ... */ }
+const timerified = performance.timerify(myFunction);
+obs.observe({ entryTypes: ['function'] });
+timerified(); // automatically measured
+```
+
+---
+
+## Custom ESM Loaders
+
+```javascript
+// register-hooks.mjs — entry point
+import { register } from 'node:module';
+register('./typescript-loader.mjs', import.meta.url);
+```
+
+```javascript
+// typescript-loader.mjs
+import { transform } from 'esbuild';
+
+export async function load(url, context, nextLoad) {
+  if (url.endsWith('.ts') || url.endsWith('.tsx')) {
+    const { source } = await nextLoad(url, { ...context, format: 'module' });
+    const { code } = await transform(source.toString(), {
+      loader: url.endsWith('.tsx') ? 'tsx' : 'ts',
+      format: 'esm',
+      target: 'node18',
+    });
+    return { format: 'module', shortCircuit: true, source: code };
+  }
+  return nextLoad(url, context);
+}
+```
+
+```bash
+# Use the loader
+node --import ./register-hooks.mjs ./src/main.ts
+```
+
+---
+
+## Permission Model (Node 22+)
+
+```javascript
+// Experimental permission model — opt-in security sandboxing
+// Deny all by default, explicitly allow what you need
+```
+
+```bash
+# Allow reading only from specific directories
+node --experimental-permission \
+     --allow-fs-read=/app/config \
+     --allow-fs-write=/app/logs \
+     --allow-net=api.example.com:443 \
+     --allow-child-process \
+     server.mjs
+
+# Check permissions at runtime
+process.permission.has('fs.read', '/app/config/db.json'); // true
+process.permission.has('fs.write', '/etc/passwd');         // false
+
+# Deny all network access
+node --experimental-permission --allow-fs-read=. --allow-fs-write=./dist bundler.mjs
+```
+
+---
+
+## Package Management Patterns
+
+### npm workspaces
+
+```json
+// Root package.json
+{
+  "name": "my-monorepo",
+  "private": true,
+  "workspaces": ["packages/*", "apps/*"]
+}
+```
+
+```bash
+# Install deps for all packages
+npm install
+
+# Run script in specific package
+npm run build --workspace=packages/my-lib
+
+# Run script in all packages
+npm run test --workspaces
+
+# Add dep to a specific workspace
+npm install zod --workspace=packages/my-lib
+```
+
+### corepack — Package Manager Version Pinning
+
+```bash
+# Enable corepack (built into Node 16.9+)
+corepack enable
+
+# Use specific pnpm version
+corepack use pnpm@9.0.0
+# Adds "packageManager": "pnpm@9.0.0" to package.json
+
+# Corepack downloads and uses exact version — no global installs
+```
+
+### package.json Best Practices
+
+```json
+{
+  "name": "my-package",
+  "version": "1.0.0",
+  "engines": {
+    "node": ">=18.17.0",
+    "npm": ">=9.0.0"
+  },
+  "packageManager": "pnpm@9.0.0",
+  "overrides": {
+    "vulnerable-package": ">=2.1.0"
+  }
+}
+```
+
+---
+
+## Graceful Shutdown
+
+```javascript
+// server.mjs — production-ready graceful shutdown
+import { createServer } from 'node:http';
+
+const server = createServer(handler);
+server.listen(3000);
+
+// Track active connections for graceful drain
+let isShuttingDown = false;
+const connections = new Set();
+
+server.on('connection', (socket) => {
+  connections.add(socket);
+  socket.on('close', () => connections.delete(socket));
+});
+
+async function shutdown(signal) {
+  if (isShuttingDown) return;
+  isShuttingDown = true;
+
+  console.log(`Received ${signal}. Starting graceful shutdown...`);
+
+  // Stop accepting new connections
+  server.close();
+
+  // Set response header to signal clients to disconnect
+  // (Connection: close is set automatically during close())
+
+  // Wait for in-flight requests (max 30s)
+  const forceShutdown = setTimeout(() => {
+    console.error('Forcing shutdown after timeout');
+    for (const socket of connections) socket.destroy();
+    process.exit(1);
+  }, 30_000);
+
+  // Wait for all connections to close naturally
+  await new Promise(resolve => server.on('close', resolve));
+  clearTimeout(forceShutdown);
+
+  // Cleanup other resources
+  await db.end();
+  await redis.quit();
+
+  console.log('Graceful shutdown complete');
+  process.exit(0);
+}
+
+process.on('SIGTERM', () => shutdown('SIGTERM'));
+process.on('SIGINT', () => shutdown('SIGINT'));
+
+// Unhandled errors — always exit
+process.on('unhandledRejection', (reason) => {
+  console.error('Unhandled rejection:', reason);
+  process.exit(1);
+});
+process.on('uncaughtException', (err) => {
+  console.error('Uncaught exception:', err);
+  process.exit(1);
+});
+```
+
+### .env File Loading (Node 21+)
+
+```bash
+# No dotenv package needed
+node --env-file=.env server.mjs
+node --env-file=.env --env-file=.env.local server.mjs  # multiple files, later wins
+```
+
+```javascript
+// Validate required env vars at startup
+const required = ['DATABASE_URL', 'API_KEY', 'JWT_SECRET'];
+const missing = required.filter(key => !process.env[key]);
+if (missing.length) {
+  throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
+}
+```
+
+### Health Check Endpoint
+
+```javascript
+// Standard health check pattern for load balancers
+import { createServer } from 'node:http';
+
+const healthServer = createServer(async (req, res) => {
+  if (req.url !== '/health') {
+    res.writeHead(404);
+    res.end();
+    return;
+  }
+
+  try {
+    // Check dependencies
+    await Promise.all([
+      db.query('SELECT 1'),
+      redis.ping(),
+    ]);
+
+    res.writeHead(200, { 'Content-Type': 'application/json' });
+    res.end(JSON.stringify({
+      status: 'healthy',
+      uptime: process.uptime(),
+      memory: process.memoryUsage(),
+      version: process.env.npm_package_version,
+    }));
+  } catch (err) {
+    res.writeHead(503, { 'Content-Type': 'application/json' });
+    res.end(JSON.stringify({ status: 'unhealthy', error: err.message }));
+  }
+});
+
+// Bind to different port from main app (accessible internally, not publicly)
+healthServer.listen(3001);
+```

+ 0 - 0
skills/javascript-ops/scripts/.gitkeep


+ 424 - 0
skills/laravel-ops/SKILL.md

@@ -0,0 +1,424 @@
+---
+name: laravel-ops
+description: "Laravel framework patterns, Eloquent ORM, authentication, queues, and testing. Use for: laravel, eloquent, artisan, blade, php, sanctum, livewire, inertia, pest, phpunit, forge, vapor, queue, middleware, migration, factory, seeder."
+allowed-tools: "Read Write Bash"
+related-skills: [sql-ops, postgres-ops, testing-ops, docker-ops]
+---
+
+# Laravel Operations
+
+Authoritative reference for Laravel 11+ development: architecture decisions, Eloquent patterns, authentication strategies, queue configuration, and testing approaches.
+
+---
+
+## Architecture Decision Tree
+
+```
+What type of application?
+│
+├─ Full-stack web (HTML responses)
+│  ├─ Simple CRUD, small team → Monolith (Blade + Eloquent directly)
+│  │   └─ Use action classes for business logic over 20 lines
+│  ├─ Rich interactivity needed → Livewire (server-driven reactivity)
+│  │   └─ Add Alpine.js for client-side micro-interactions
+│  └─ SPA-like feel, React/Vue team → Inertia.js
+│      └─ Keep server-side routing, dump client-side routing overhead
+│
+├─ API backend (JSON responses)
+│  ├─ Single consumer (mobile/SPA) → API-only with Sanctum SPA auth
+│  ├─ Multiple consumers / public → RESTful API with token auth
+│  └─ Complex graph queries → Consider GraphQL (lighthouse-php/lighthouse)
+│
+├─ Large team / complex domain
+│  ├─ Domain-driven → Modular monolith (app/Modules/{Domain}/)
+│  │   ├─ Each module: Models, Actions, Events, Jobs, Http/
+│  │   └─ Shared: app/Shared/ for cross-cutting concerns
+│  └─ Independent deployability needed → Microservices
+│      └─ Use Laravel Octane for high-throughput services
+│
+└─ What business logic pattern?
+   ├─ Simple CRUD, < 20 lines → Direct Eloquent in controller
+   ├─ Reusable operation (create order, send invoice) → Action class
+   │   └─ Single public handle() or execute() method
+   ├─ Complex queries, multiple data sources → Repository pattern
+   │   └─ Interface + Eloquent implementation (enables swapping)
+   └─ Cross-cutting operations (audit, caching) → Service class
+       └─ Inject via constructor, bind in ServiceProvider
+```
+
+### Action Class vs Repository vs Service
+
+| Pattern | Use When | Example |
+|---------|----------|---------|
+| Action class | Single, reusable business operation | `CreateOrderAction`, `SendInvoiceAction` |
+| Repository | Abstract data access, multiple sources | `OrderRepository` with `EloquentOrderRepository` |
+| Service | Orchestrate multiple actions/repos | `OrderService` combining payment + inventory |
+| Direct Eloquent | Simple CRUD, < 5 lines in controller | `User::create($data)` |
+
+---
+
+## Eloquent Quick Reference
+
+### Relationships
+
+| Relationship | Method | Foreign Key Convention |
+|-------------|--------|----------------------|
+| `hasOne` | `return $this->hasOne(Profile::class)` | `profiles.user_id` |
+| `hasMany` | `return $this->hasMany(Post::class)` | `posts.user_id` |
+| `belongsTo` | `return $this->belongsTo(User::class)` | `posts.user_id` |
+| `belongsToMany` | `return $this->belongsToMany(Role::class)` | `role_user` pivot |
+| `hasManyThrough` | `return $this->hasManyThrough(Post::class, User::class)` | Country → User → Post |
+| `morphTo` | `return $this->morphTo()` | `{col}_type`, `{col}_id` |
+| `morphMany` | `return $this->morphMany(Comment::class, 'commentable')` | Polymorphic |
+| `morphToMany` | `return $this->morphToMany(Tag::class, 'taggable')` | Polymorphic pivot |
+
+### Eager Loading
+
+```php
+// Prevent N+1: always eager load in controllers
+$posts = Post::with(['author', 'comments.author', 'tags'])->paginate(15);
+
+// Conditional eager loading (load after retrieval)
+$user->load('posts.comments');
+$user->loadMissing('posts'); // only if not already loaded
+
+// Eager load counts (no SELECT *)
+$posts = Post::withCount('comments')->get();
+
+// Constrained eager loading
+$posts = Post::with(['comments' => fn($q) => $q->approved()->latest()])->get();
+```
+
+### Query Scopes
+
+```php
+// Local scope (reusable query constraint)
+public function scopeActive(Builder $query): void
+{
+    $query->where('status', 'active');
+}
+
+// Usage: User::active()->get()
+
+// Dynamic scope
+public function scopeOfType(Builder $query, string $type): void
+{
+    $query->where('type', $type);
+}
+// Usage: User::ofType('admin')->get()
+```
+
+### Mass Assignment
+
+```php
+// Fillable (allowlist - preferred)
+protected $fillable = ['name', 'email', 'password'];
+
+// Guarded (denylist - use [] only if you trust all input)
+protected $guarded = ['id', 'is_admin'];
+
+// Never set guarded = [] in production code
+```
+
+---
+
+## Artisan Command Cheat Sheet
+
+| Command | Purpose | Common Options |
+|---------|---------|----------------|
+| `make:model Post -mfs` | Model + migration + factory + seeder | `-c` controller, `-r` resource |
+| `make:controller PostController -r` | Resource controller (7 methods) | `--api` skips create/edit |
+| `make:request StorePostRequest` | Form request for validation | |
+| `make:job ProcessPayment` | Queueable job class | `--sync` for sync job |
+| `make:event OrderPlaced` | Event class | |
+| `make:listener SendOrderConfirmation -e OrderPlaced` | Listener for event | `--queued` |
+| `make:notification InvoicePaid` | Notification class | |
+| `make:policy PostPolicy -m Post` | Policy with model | |
+| `make:middleware EnsureUserIsAdmin` | HTTP middleware | |
+| `make:command SendDailyReport` | Custom Artisan command | |
+| `migrate` | Run pending migrations | `--step` for individual |
+| `migrate:rollback` | Roll back last batch | `--step=5` |
+| `migrate:fresh --seed` | Drop all + re-migrate + seed | |
+| `db:seed` | Run all seeders | `--class=UserSeeder` |
+| `tinker` | REPL with app context | |
+| `route:list` | Show all routes | `--name=api` filter |
+| `route:cache` | Cache routes for production | |
+| `config:cache` | Cache config for production | |
+| `view:cache` | Pre-compile Blade templates | |
+| `optimize` | Run all cache commands | `optimize:clear` to reset |
+| `queue:work` | Process queue jobs | `--queue=high,default` |
+| `queue:listen` | Work + auto-reload on code change | |
+| `queue:failed` | List failed jobs | |
+| `queue:retry all` | Retry all failed jobs | |
+| `schedule:run` | Run due scheduled tasks | |
+| `schedule:work` | Run scheduler every minute (dev) | |
+| `key:generate` | Generate APP_KEY | |
+| `test` | Run PHPUnit/Pest tests | `--filter=UserTest` |
+| `test --parallel` | Run tests in parallel | `--processes=4` |
+| `vendor:publish` | Publish package assets/config | `--tag=config` |
+
+---
+
+## Authentication Decision Tree
+
+```
+What do you need?
+│
+├─ SPA (Vue/React) + Laravel API backend
+│  └─ Sanctum SPA authentication
+│     ├─ Cookie-based (same domain or subdomain)
+│     ├─ Csrf-cookie endpoint: GET /sanctum/csrf-cookie
+│     └─ No tokens in localStorage (XSS safe)
+│
+├─ Mobile app or third-party API consumers
+│  └─ Sanctum API tokens (Bearer tokens)
+│     ├─ createToken($name, $abilities)
+│     ├─ Token abilities for fine-grained control
+│     └─ Token expiration with token:prune schedule
+│
+├─ Traditional web app (server-rendered)
+│  ├─ Just need auth pages quickly → Breeze
+│  │   ├─ Minimal, educational, Blade or Inertia stack
+│  │   └─ Install: composer require laravel/breeze --dev
+│  ├─ Need teams, 2FA, profile management → Jetstream
+│  │   ├─ Livewire or Inertia stack
+│  │   └─ Install: composer require laravel/jetstream
+│  └─ Need headless auth (API + custom UI) → Fortify
+│      ├─ Actions in app/Actions/Fortify/
+│      └─ Customize: CreateNewUser, UpdateUserPassword
+│
+└─ Custom / enterprise
+   ├─ LDAP/SAML → socialiteproviders/saml2
+   ├─ OAuth social login → laravel/socialite
+   └─ Custom guard → Implement Guard + UserProvider contracts
+```
+
+### Sanctum Quick Setup
+
+```php
+// config/sanctum.php - stateful domains for SPA
+'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost')),
+
+// API token creation
+$token = $user->createToken('mobile-app', ['orders:read', 'orders:write']);
+return ['token' => $token->plainTextToken];
+
+// Check token ability
+Route::get('/orders', function (Request $request) {
+    $request->user()->tokenCan('orders:read'); // bool
+});
+
+// Protect routes
+Route::middleware('auth:sanctum')->group(function () {
+    // authenticated routes
+});
+```
+
+---
+
+## Queue Decision Tree
+
+```
+Queue driver selection:
+│
+├─ Development / testing
+│  └─ sync driver (executes immediately, no worker needed)
+│     QUEUE_CONNECTION=sync
+│
+├─ Small app, no Redis available
+│  └─ database driver
+│     ├─ php artisan queue:table && migrate
+│     ├─ Works fine for < 100 jobs/min
+│     └─ QUEUE_CONNECTION=database
+│
+├─ Medium-high throughput, self-hosted
+│  └─ Redis driver (via predis or phpredis)
+│     ├─ QUEUE_CONNECTION=redis
+│     ├─ Laravel Horizon for monitoring
+│     └─ Supports priorities, pausing, metrics
+│
+└─ AWS infrastructure / massive scale
+   └─ SQS driver
+      ├─ QUEUE_CONNECTION=sqs
+      ├─ Managed, auto-scaling
+      └─ Use with Laravel Vapor for serverless
+```
+
+### Job Patterns
+
+```php
+// Basic job dispatch
+ProcessPayment::dispatch($order);
+ProcessPayment::dispatch($order)->onQueue('payments')->delay(now()->addMinutes(5));
+
+// Chaining (sequential)
+Bus::chain([
+    new ProcessPayment($order),
+    new SendInvoice($order),
+    new UpdateInventory($order),
+])->dispatch();
+
+// Batching (parallel + callback)
+$batch = Bus::batch([
+    new ImportRow($row1),
+    new ImportRow($row2),
+    new ImportRow($row3),
+])->then(fn(Batch $batch) => ImportComplete::dispatch())
+  ->catch(fn(Batch $batch, Throwable $e) => Log::error($e))
+  ->dispatch();
+
+// Rate limiting (throttle to 5 per minute)
+public function middleware(): array
+{
+    return [new RateLimited('payments')];
+}
+
+// Unique jobs (prevent duplicate processing)
+use Illuminate\Contracts\Queue\ShouldBeUnique;
+
+class ProcessPayment implements ShouldQueue, ShouldBeUnique
+{
+    public string $uniqueId => $this->order->id;
+    public int $uniqueFor = 3600; // seconds
+}
+
+// Retry configuration
+public int $tries = 3;
+public int $backoff = 60; // seconds between retries
+
+public function retryUntil(): DateTime
+{
+    return now()->addHours(24);
+}
+```
+
+### Task Scheduling
+
+```php
+// routes/console.php (Laravel 11+)
+Schedule::job(SendDailyReport::class)->dailyAt('08:00')->timezone('America/New_York');
+Schedule::command('backup:run')->daily()->runInBackground()->emailOutputOnFailure('ops@app.com');
+Schedule::call(fn() => Cache::flush())->weekly()->sundays()->at('00:00');
+
+// Prevent overlap (long-running tasks)
+Schedule::job(ProcessImport::class)->everyFiveMinutes()->withoutOverlapping();
+
+// Run on one server only (requires Redis/database cache driver)
+Schedule::job(SendNewsletters::class)->daily()->onOneServer();
+```
+
+---
+
+## Testing Quick Reference
+
+### Test Types
+
+| Type | Class extends | Database | Purpose |
+|------|--------------|----------|---------|
+| Feature test | `Tests\TestCase` | Yes (with trait) | HTTP endpoints, full stack |
+| Unit test | `PHPUnit\Framework\TestCase` | No | Pure logic, no app boot |
+| Browser test | `Laravel\Dusk\TestCase` | Yes | Real browser via ChromeDriver |
+
+### Database Traits
+
+```php
+use Illuminate\Foundation\Testing\RefreshDatabase;   // migrate fresh each test (slower)
+use Illuminate\Foundation\Testing\DatabaseTransactions; // rollback each test (faster)
+```
+
+### Pest Syntax (preferred in Laravel 11+)
+
+```php
+describe('User authentication', function () {
+    beforeEach(function () {
+        $this->user = User::factory()->create();
+    });
+
+    it('allows login with valid credentials', function () {
+        $response = $this->post('/login', [
+            'email' => $this->user->email,
+            'password' => 'password',
+        ]);
+
+        $response->assertRedirect('/dashboard');
+        $this->assertAuthenticatedAs($this->user);
+    });
+
+    it('rejects invalid credentials')->todo();
+});
+```
+
+### Common Assertions
+
+```php
+// HTTP response
+$response->assertStatus(200);
+$response->assertOk();             // 200
+$response->assertCreated();        // 201
+$response->assertNoContent();      // 204
+$response->assertUnauthorized();   // 401
+$response->assertForbidden();      // 403
+$response->assertNotFound();       // 404
+$response->assertRedirect('/home');
+
+// JSON responses
+$response->assertJson(['status' => 'ok']);
+$response->assertJsonPath('data.email', 'user@example.com');
+$response->assertJsonCount(3, 'data');
+$response->assertJsonStructure(['data' => ['id', 'name', 'email']]);
+$response->assertJsonMissing(['password']);
+
+// Database
+$this->assertDatabaseHas('users', ['email' => 'user@example.com']);
+$this->assertDatabaseMissing('users', ['email' => 'deleted@example.com']);
+$this->assertDatabaseCount('posts', 5);
+$this->assertSoftDeleted('posts', ['id' => $post->id]);
+```
+
+---
+
+## Common Gotchas
+
+| Gotcha | Why | Fix |
+|--------|-----|-----|
+| N+1 queries on relationships | Eloquent lazy-loads by default | Use `with()` eager loading; enable `Model::preventLazyLoading()` in AppServiceProvider during development |
+| Mass assignment vulnerability | `$fillable = []` accepts all | Always define `$fillable`; never use `$guarded = []` in production |
+| `created_at` not updating on `update()` | Only `updated_at` auto-sets | Use `$model->touch()` or `timestamps = true` (default) |
+| Queue job fails on model serialization | Model state may change between dispatch and processing | Use `SerializesModels` trait; re-fetch from DB in `handle()` if needed |
+| Timezone mismatch in scheduled tasks | Server tz != app tz | Set `APP_TIMEZONE` in `.env`; use `->timezone()` on schedule entries |
+| Middleware order matters | Auth middleware must run before policies | Global → route group → route. Auth before throttle check or vice versa changes 401 vs 429 |
+| Route model binding skips soft-deleted records | `RouteServiceProvider` ignores `trashed()` | Extend binding: `Route::bind('post', fn($id) => Post::withTrashed()->findOrFail($id))` |
+| Service container binding not auto-resolved | Interface not bound to implementation | Register in `AppServiceProvider::register()`: `$this->app->bind(Interface::class, Implementation::class)` |
+| Migration foreign key order | Must create referenced table first | Run `migrate:fresh` to verify; use `Schema::disableForeignKeyConstraints()` in tests |
+| CSRF protection blocks API routes | `VerifyCsrfToken` runs on all web routes | Register API routes in `routes/api.php` (uses `api` middleware group without CSRF) |
+| `env()` returns null after caching | `config:cache` bakes env values | Always access env via `config()` helper in app code; only use `env()` in `config/` files |
+| Blade `@stack` renders in wrong order | `@push` must appear after `@stack` in execution | Use `@prepend` for scripts that must appear first |
+| Event listener not firing | Listener not registered or discovered | Check `EventServiceProvider::$listen`; or enable `Event::discover()` in Laravel 11 |
+
+---
+
+## Reference Files
+
+| File | Contents |
+|------|---------|
+| `references/eloquent-queries.md` | Deep-dive: relationships, query builder, scopes, accessors, mutators, events, soft deletes, pagination, performance, collections, factories |
+| `references/architecture.md` | Service container, providers, facades, middleware, events, notifications, jobs, scheduling, Blade components, Livewire, Inertia |
+| `references/testing-auth.md` | PHPUnit/Pest setup, HTTP tests, database testing, fakes, Sanctum, Fortify, policies, form requests, Dusk |
+
+---
+
+## See Also
+
+- `sql-ops` - Query optimization, indexing strategy, raw SQL patterns
+- `postgres-ops` - PostgreSQL-specific features, JSON columns, full-text search
+- `testing-ops` - General testing philosophy, TDD, CI integration
+- `docker-ops` - Containerizing Laravel apps, Docker Compose, production setup
+
+### Key External Resources
+
+- [Laravel 11 Documentation](https://laravel.com/docs/11.x)
+- [Pest PHP](https://pestphp.com/)
+- [Laravel Horizon](https://laravel.com/docs/11.x/horizon) - Queue monitoring
+- [Laravel Octane](https://laravel.com/docs/11.x/octane) - High-performance serving
+- [Laravel Forge](https://forge.laravel.com/) - Server management
+- [Laravel Vapor](https://vapor.laravel.com/) - Serverless deployment

+ 0 - 0
skills/laravel-ops/assets/.gitkeep


+ 962 - 0
skills/laravel-ops/references/architecture.md

@@ -0,0 +1,962 @@
+# Laravel Architecture Reference
+
+Deep-dive reference for Laravel 11+ architecture: service container, providers, facades, middleware, events, notifications, jobs, scheduling, Blade, Livewire, and Inertia.
+
+---
+
+## Service Container
+
+The container resolves class dependencies automatically via reflection.
+
+### Binding Types
+
+```php
+// AppServiceProvider::register()
+
+// Bind (new instance each resolution)
+$this->app->bind(PaymentGateway::class, StripeGateway::class);
+$this->app->bind(PaymentGateway::class, function ($app) {
+    return new StripeGateway($app->make(HttpClient::class), config('stripe.key'));
+});
+
+// Singleton (same instance every resolution)
+$this->app->singleton(AnalyticsService::class, function ($app) {
+    return new AnalyticsService($app->make(Logger::class));
+});
+
+// Instance (bind a pre-existing object)
+$this->app->instance(Config::class, new Config(['debug' => true]));
+
+// Scoped (singleton per request lifecycle - useful with Octane)
+$this->app->scoped(RequestContext::class, function ($app) {
+    return new RequestContext($app->make(Request::class));
+});
+```
+
+### Contextual Binding
+
+```php
+// Give different implementations to different classes
+$this->app->when(PhotoController::class)
+          ->needs(Filesystem::class)
+          ->give(fn() => Storage::disk('photos'));
+
+$this->app->when(VideoController::class)
+          ->needs(Filesystem::class)
+          ->give(fn() => Storage::disk('videos'));
+
+// Bind tagged implementations
+$this->app->bind(CsvReport::class, fn() => new CsvReport());
+$this->app->bind(PdfReport::class, fn() => new PdfReport());
+$this->app->tag([CsvReport::class, PdfReport::class], 'reports');
+
+$reports = $this->app->tagged('reports'); // array of resolved instances
+```
+
+### Auto-Resolution and Method Injection
+
+```php
+// Constructor injection (auto-resolved)
+class OrderService
+{
+    public function __construct(
+        private readonly PaymentGateway $payment,
+        private readonly InventoryRepository $inventory,
+        private readonly EventDispatcher $events,
+    ) {}
+}
+
+// Call with method injection
+$result = app()->call([OrderService::class, 'process'], ['orderId' => 123]);
+
+// Resolve with makeWith (pass primitives)
+$service = app()->makeWith(ReportService::class, ['format' => 'pdf']);
+```
+
+---
+
+## Service Providers
+
+### Structure
+
+```php
+class AppServiceProvider extends ServiceProvider
+{
+    // Bindings array - simple alias
+    public array $bindings = [
+        OrderRepositoryInterface::class => EloquentOrderRepository::class,
+    ];
+
+    // Singletons array
+    public array $singletons = [
+        CurrencyConverter::class => CurrencyConverter::class,
+    ];
+
+    // register(): bind into container (no other services available yet)
+    public function register(): void
+    {
+        $this->app->bind(PaymentGateway::class, fn($app) => new StripeGateway(
+            config('services.stripe.key')
+        ));
+    }
+
+    // boot(): everything is registered, safe to use facades and other services
+    public function boot(): void
+    {
+        Model::preventLazyLoading(! $this->app->isProduction());
+        Blade::directive('money', fn($amount) => "<?php echo money_format({$amount}); ?>");
+        Post::observe(PostObserver::class);
+        Validator::extend('phone', [PhoneValidator::class, 'validate']);
+    }
+}
+```
+
+### Deferred Providers
+
+```php
+// Only loaded when the binding is actually requested
+class ReportServiceProvider extends ServiceProvider implements DeferrableProvider
+{
+    public function register(): void
+    {
+        $this->app->singleton(ReportGenerator::class, fn() => new ReportGenerator());
+    }
+
+    public function provides(): array
+    {
+        return [ReportGenerator::class]; // what this provider resolves
+    }
+}
+```
+
+### Package Service Providers
+
+```php
+class PackageServiceProvider extends ServiceProvider
+{
+    public function register(): void
+    {
+        $this->mergeConfigFrom(__DIR__.'/../config/package.php', 'package');
+    }
+
+    public function boot(): void
+    {
+        // Publish config
+        $this->publishes([
+            __DIR__.'/../config/package.php' => config_path('package.php'),
+        ], 'config');
+
+        // Publish migrations
+        $this->publishes([
+            __DIR__.'/../database/migrations' => database_path('migrations'),
+        ], 'migrations');
+
+        // Load migrations without publishing
+        $this->loadMigrationsFrom(__DIR__.'/../database/migrations');
+
+        // Load routes
+        $this->loadRoutesFrom(__DIR__.'/../routes/web.php');
+
+        // Load views (with namespace prefix)
+        $this->loadViewsFrom(__DIR__.'/../resources/views', 'package');
+    }
+}
+```
+
+---
+
+## Facades
+
+Facades provide a static interface to services in the container.
+
+```php
+// How facades work internally
+Cache::get('key');
+// resolves to: app('cache')->get('key')
+
+// Real-time facades (prefix with Facades\)
+use Facades\App\Services\PaymentGateway;
+PaymentGateway::charge($amount); // automatically resolved from container
+
+// All standard facades
+use Illuminate\Support\Facades\{
+    App, Artisan, Auth, Blade, Bus, Cache, Config, Cookie, Crypt,
+    DB, Event, File, Gate, Hash, Http, Log, Mail, Notification,
+    Queue, Redirect, Request, Response, Route, Schema, Session,
+    Storage, URL, Validator, View
+};
+```
+
+### Testing with Facade Fakes
+
+```php
+// In test setup - swap real implementation with fake
+Event::fake();
+Mail::fake();
+Notification::fake();
+Queue::fake();
+Bus::fake();
+Storage::fake('s3');
+Http::fake(['api.stripe.com/*' => Http::response(['id' => 'ch_123'], 200)]);
+
+// Then assert interactions
+Event::assertDispatched(OrderPlaced::class, fn($e) => $e->order->id === $orderId);
+Event::assertNotDispatched(OrderCancelled::class);
+Mail::assertSent(InvoiceMail::class, fn($mail) => $mail->hasTo('user@example.com'));
+Notification::assertSentTo($user, InvoicePaidNotification::class);
+Queue::assertPushed(ProcessPayment::class, fn($job) => $job->order->id === $orderId);
+Queue::assertPushedOn('high-priority', ProcessPayment::class);
+```
+
+---
+
+## Middleware
+
+### Defining Middleware
+
+```php
+// php artisan make:middleware EnsureUserIsSubscribed
+
+class EnsureUserIsSubscribed
+{
+    public function handle(Request $request, Closure $next): Response
+    {
+        if (! $request->user()?->subscribed()) {
+            return redirect('/billing')->with('error', 'Subscription required.');
+        }
+
+        return $next($request);
+    }
+}
+
+// Middleware with parameters
+class EnsureRole
+{
+    public function handle(Request $request, Closure $next, string ...$roles): Response
+    {
+        if (! $request->user()->hasAnyRole($roles)) {
+            abort(403);
+        }
+        return $next($request);
+    }
+}
+// Route: Route::middleware('role:admin,editor')->group(...)
+```
+
+### Registering Middleware (Laravel 11+)
+
+```php
+// bootstrap/app.php
+->withMiddleware(function (Middleware $middleware) {
+    // Global middleware
+    $middleware->append(LogHttpRequests::class);
+    $middleware->prepend(TrustProxies::class);
+
+    // Named middleware aliases
+    $middleware->alias([
+        'subscribed' => EnsureUserIsSubscribed::class,
+        'role'       => EnsureRole::class,
+    ]);
+
+    // Middleware groups
+    $middleware->group('api', [
+        ThrottleRequests::class.':api',
+        SubstituteBindings::class,
+    ]);
+
+    // Exclude from global middleware
+    $middleware->except([VerifyCsrfToken::class], ['/webhooks/*']);
+})
+```
+
+### Terminable Middleware
+
+```php
+// Runs AFTER response is sent (for cleanup, logging)
+class LogResponseTime implements TerminableMiddleware
+{
+    private float $startTime;
+
+    public function handle(Request $request, Closure $next): Response
+    {
+        $this->startTime = microtime(true);
+        return $next($request);
+    }
+
+    public function terminate(Request $request, Response $response): void
+    {
+        $duration = microtime(true) - $this->startTime;
+        Log::channel('performance')->info('Request completed', [
+            'url'      => $request->fullUrl(),
+            'duration' => round($duration * 1000, 2) . 'ms',
+            'status'   => $response->getStatusCode(),
+        ]);
+    }
+}
+```
+
+### Rate Limiting
+
+```php
+// AppServiceProvider::boot() or RouteServiceProvider
+RateLimiter::for('api', function (Request $request) {
+    return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
+});
+
+RateLimiter::for('uploads', function (Request $request) {
+    return [
+        Limit::perMinute(10)->by($request->user()->id),     // per user
+        Limit::perDay(100)->by($request->user()->id),       // daily cap
+    ];
+});
+
+// Route-level: Route::middleware('throttle:api')->group(...)
+```
+
+---
+
+## Events and Listeners
+
+### Event Classes
+
+```php
+// php artisan make:event OrderPlaced
+class OrderPlaced
+{
+    use Dispatchable, InteractsWithSockets, SerializesModels;
+
+    public function __construct(
+        public readonly Order $order,
+        public readonly User $customer,
+    ) {}
+
+    // Broadcast over WebSockets (optional)
+    public function broadcastOn(): array
+    {
+        return [new PrivateChannel("orders.{$this->order->id}")];
+    }
+}
+```
+
+### Listener Classes
+
+```php
+// php artisan make:listener SendOrderConfirmation --event=OrderPlaced
+class SendOrderConfirmation implements ShouldQueue
+{
+    use InteractsWithQueue;
+
+    public string $queue = 'notifications';
+    public int $tries = 3;
+
+    public function handle(OrderPlaced $event): void
+    {
+        Mail::to($event->customer)->send(new OrderConfirmationMail($event->order));
+    }
+
+    public function failed(OrderPlaced $event, Throwable $exception): void
+    {
+        Log::error('Failed to send order confirmation', ['order_id' => $event->order->id]);
+    }
+}
+```
+
+### Event Discovery (Laravel 11+)
+
+```php
+// bootstrap/app.php - auto-discover listeners in app/Listeners
+->withEvents(function (Dispatcher $events) {
+    $events->listen(OrderPlaced::class, SendOrderConfirmation::class);
+    $events->listen(OrderPlaced::class, UpdateInventory::class);
+    // Or enable auto-discovery:
+    // $events->discover(app_path('Listeners'));
+})
+
+// Dispatch
+OrderPlaced::dispatch($order, $user);
+event(new OrderPlaced($order, $user));  // equivalent
+```
+
+---
+
+## Notifications
+
+### Notification Class
+
+```php
+// php artisan make:notification InvoicePaid
+class InvoicePaid extends Notification implements ShouldQueue
+{
+    public function __construct(private readonly Invoice $invoice) {}
+
+    // Which channels to send on
+    public function via(object $notifiable): array
+    {
+        return $notifiable->prefers_sms
+            ? ['mail', 'vonage']
+            : ['mail', 'database'];
+    }
+
+    // Email channel
+    public function toMail(object $notifiable): MailMessage
+    {
+        return (new MailMessage)
+            ->subject("Invoice #{$this->invoice->number} paid")
+            ->greeting("Hello {$notifiable->name},")
+            ->line("Your invoice of {$this->invoice->amount_formatted} has been paid.")
+            ->action('View Invoice', route('invoices.show', $this->invoice))
+            ->line('Thank you for your business!');
+    }
+
+    // Database channel
+    public function toDatabase(object $notifiable): array
+    {
+        return [
+            'invoice_id' => $this->invoice->id,
+            'amount'     => $this->invoice->amount,
+            'paid_at'    => now()->toISOString(),
+        ];
+    }
+
+    // Vonage (SMS) channel
+    public function toVonage(object $notifiable): VonageMessage
+    {
+        return (new VonageMessage)
+            ->content("Invoice #{$this->invoice->number} paid. Amount: {$this->invoice->amount_formatted}");
+    }
+
+    // Slack channel (via laravel/slack-notification-channel)
+    public function toSlack(object $notifiable): SlackMessage
+    {
+        return (new SlackMessage)
+            ->success()
+            ->content("Invoice paid: #{$this->invoice->number}");
+    }
+}
+
+// Sending
+$user->notify(new InvoicePaid($invoice));               // via model
+Notification::send($users, new InvoicePaid($invoice));  // to collection
+Notification::route('mail', 'ops@app.com')              // on-demand
+             ->notify(new InvoicePaid($invoice));
+
+// Database notifications
+$user->unreadNotifications;
+$user->notifications()->markAsRead();
+```
+
+---
+
+## Jobs and Queues
+
+### Job Class Structure
+
+```php
+// php artisan make:job ProcessPayment
+class ProcessPayment implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public int $tries = 3;
+    public int $timeout = 90;
+    public int $backoff = 60;           // seconds between retries
+    public bool $deleteWhenMissingModels = true;
+
+    public function __construct(
+        private readonly Order $order,
+        private readonly string $paymentMethodId,
+    ) {}
+
+    public function handle(PaymentGateway $gateway): void
+    {
+        // Re-fetch model (may have changed since dispatch)
+        $order = Order::find($this->order->id);
+
+        $charge = $gateway->charge($order->total, $this->paymentMethodId);
+        $order->update(['payment_id' => $charge->id, 'status' => 'paid']);
+        OrderPaid::dispatch($order);
+    }
+
+    // Exponential backoff per attempt
+    public function backoff(): array
+    {
+        return [30, 60, 120]; // wait 30s, 60s, 120s between retries
+    }
+
+    // Called when all retries exhausted
+    public function failed(Throwable $exception): void
+    {
+        $this->order->update(['status' => 'payment_failed']);
+        Log::error('Payment failed', ['order_id' => $this->order->id, 'error' => $exception->getMessage()]);
+    }
+
+    // Middleware on job
+    public function middleware(): array
+    {
+        return [
+            new RateLimited('payments'),
+            new WithoutOverlapping($this->order->id), // prevent duplicate processing
+        ];
+    }
+}
+
+// Dispatch patterns
+ProcessPayment::dispatch($order, $paymentMethodId);
+ProcessPayment::dispatch($order, $paymentMethodId)->onQueue('payments');
+ProcessPayment::dispatch($order, $paymentMethodId)->delay(now()->addSeconds(30));
+ProcessPayment::dispatchSync($order, $paymentMethodId); // synchronous (bypasses queue)
+ProcessPayment::dispatchIf($order->requiresPayment(), $order, $paymentMethodId);
+ProcessPayment::dispatchUnless($order->isFree(), $order, $paymentMethodId);
+```
+
+### Batches and Chains
+
+```php
+// Chain (sequential - each waits for previous to complete)
+Bus::chain([
+    new ValidateOrder($order),
+    new ProcessPayment($order, $method),
+    new SendConfirmation($order),
+])->onQueue('orders')
+  ->catch(fn(Throwable $e) => $order->markAsFailed($e->getMessage()))
+  ->dispatch();
+
+// Batch (parallel - all run concurrently)
+$batch = Bus::batch(
+    $rows->map(fn($row) => new ImportRow($row))->all()
+)->then(function (Batch $batch) {
+    ImportComplete::dispatch($batch->id);
+})->catch(function (Batch $batch, Throwable $e) {
+    Log::error('Batch failed', ['id' => $batch->id]);
+})->finally(function (Batch $batch) {
+    // always runs
+})->name('CSV Import')
+  ->allowFailures()          // don't cancel on single failure
+  ->onQueue('imports')
+  ->dispatch();
+
+// Monitor batch
+$batch = Bus::findBatch($batchId);
+$batch->totalJobs;           // int
+$batch->processedJobs();     // int
+$batch->failedJobs;          // int
+$batch->progress();          // 0-100
+$batch->finished();          // bool
+```
+
+---
+
+## Task Scheduling
+
+```php
+// routes/console.php (Laravel 11+)
+use Illuminate\Support\Facades\Schedule;
+
+// Frequency methods
+Schedule::job(GenerateSitemap::class)->daily();
+Schedule::job(SendNewsletters::class)->weekdays()->at('08:00');
+Schedule::command('reports:monthly')->monthlyOn(1, '00:30');
+Schedule::command('cache:prune')->everyFiveMinutes()->withoutOverlapping(10); // lock for 10 min max
+Schedule::call(fn() => DB::table('logs')->where('created_at', '<', now()->subDays(90))->delete())
+         ->weekly()->sundays();
+
+// Output and notification
+Schedule::command('backup:run')
+         ->daily()
+         ->runInBackground()
+         ->appendOutputTo(storage_path('logs/backup.log'))
+         ->emailOutputOnFailure('ops@app.com')
+         ->pingOnSuccess(env('HEALTHCHECK_URL'));
+
+// Run on one server (distributed lock via cache)
+Schedule::job(SendDailyDigest::class)->daily()->onOneServer();
+
+// Environment constraints
+Schedule::command('sync:users')->hourly()->environments(['production']);
+
+// Maintenance mode bypass
+Schedule::job(HeartbeatCheck::class)->everyMinute()->evenInMaintenanceMode();
+
+// Chained callbacks
+Schedule::call(function () {
+    // ...
+})->before(fn() => Log::info('Starting'))
+  ->after(fn() => Log::info('Complete'));
+```
+
+---
+
+## Blade Components
+
+### Anonymous Components
+
+```blade
+{{-- resources/views/components/alert.blade.php --}}
+@props(['type' => 'info', 'dismissible' => false])
+
+<div class="alert alert-{{ $type }} {{ $dismissible ? 'alert-dismissible' : '' }}">
+    {{ $slot }}
+    @if($dismissible)
+        <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
+    @endif
+</div>
+
+{{-- Usage --}}
+<x-alert type="danger" dismissible>
+    Something went wrong.
+</x-alert>
+```
+
+### Named Slots
+
+```blade
+{{-- resources/views/components/modal.blade.php --}}
+@props(['id', 'title'])
+
+<div id="{{ $id }}" class="modal">
+    <div class="modal-header">
+        <h5>{{ $title }}</h5>
+    </div>
+    <div class="modal-body">
+        {{ $slot }}
+    </div>
+    <div class="modal-footer">
+        {{ $footer ?? '' }}
+    </div>
+</div>
+
+{{-- Usage --}}
+<x-modal id="confirm-delete" title="Confirm Delete">
+    Are you sure you want to delete this item?
+
+    <x-slot:footer>
+        <button>Cancel</button>
+        <button class="btn-danger">Delete</button>
+    </x-slot:footer>
+</x-modal>
+```
+
+### Class-Based Components
+
+```php
+// php artisan make:component UserCard
+class UserCard extends Component
+{
+    public readonly string $initials;
+
+    public function __construct(
+        public readonly User $user,
+        public bool $showEmail = false,
+    ) {
+        $this->initials = strtoupper(
+            substr($user->first_name, 0, 1) . substr($user->last_name, 0, 1)
+        );
+    }
+
+    public function render(): View
+    {
+        return view('components.user-card');
+    }
+}
+
+{{-- resources/views/components/user-card.blade.php --}}
+<div class="user-card">
+    <div class="avatar">{{ $initials }}</div>
+    <h3>{{ $user->name }}</h3>
+    @if($showEmail)
+        <p>{{ $user->email }}</p>
+    @endif
+</div>
+```
+
+### Stacks and Sections
+
+```blade
+{{-- layout.blade.php --}}
+<html>
+    <head>
+        @stack('styles')       {{-- filled by child views --}}
+    </head>
+    <body>
+        @yield('content')
+        @stack('scripts')
+    </body>
+</html>
+
+{{-- child.blade.php --}}
+@extends('layout')
+
+@push('styles')
+    <link rel="stylesheet" href="/css/dashboard.css">
+@endpush
+
+@section('content')
+    <h1>Dashboard</h1>
+@endsection
+
+@push('scripts')
+    <script src="/js/dashboard.js"></script>
+@endpush
+```
+
+---
+
+## Livewire Integration
+
+Livewire 3 handles server-side state with automatic DOM diffing.
+
+### Component Structure
+
+```php
+// php artisan make:livewire SearchUsers
+
+use Livewire\Attributes\{Computed, Url};
+use Livewire\Component;
+
+class SearchUsers extends Component
+{
+    #[Url]                          // syncs to query string
+    public string $search = '';
+
+    public string $sortBy = 'name';
+    public bool $showModal = false;
+
+    // Runs when $search changes (debounced in view)
+    public function updatedSearch(): void
+    {
+        $this->resetPage();
+    }
+
+    // Computed property (cached per render)
+    #[Computed]
+    public function users(): LengthAwarePaginator
+    {
+        return User::where('name', 'like', "%{$this->search}%")
+                   ->orderBy($this->sortBy)
+                   ->paginate(10);
+    }
+
+    public function deleteUser(int $userId): void
+    {
+        $this->authorize('delete', User::find($userId));
+        User::destroy($userId);
+        $this->dispatch('user-deleted'); // JS event
+    }
+
+    public function render(): View
+    {
+        return view('livewire.search-users');
+    }
+}
+```
+
+```blade
+{{-- resources/views/livewire/search-users.blade.php --}}
+<div>
+    <input wire:model.live.debounce.300ms="search" placeholder="Search...">
+
+    <select wire:model.live="sortBy">
+        <option value="name">Name</option>
+        <option value="created_at">Newest</option>
+    </select>
+
+    @foreach($this->users as $user)
+        <div wire:key="{{ $user->id }}">
+            {{ $user->name }}
+            <button wire:click="deleteUser({{ $user->id }})"
+                    wire:confirm="Are you sure?">
+                Delete
+            </button>
+        </div>
+    @endforeach
+
+    {{ $this->users->links() }}
+
+    {{-- Lazy loading --}}
+    <livewire:heavy-chart lazy />
+</div>
+```
+
+### Livewire File Uploads
+
+```php
+use Livewire\WithFileUploads;
+
+class UploadAvatar extends Component
+{
+    use WithFileUploads;
+
+    #[Validate('image|max:1024')]
+    public $photo;
+
+    public function save(): void
+    {
+        $path = $this->photo->store('avatars', 's3');
+        auth()->user()->update(['avatar' => $path]);
+    }
+}
+```
+
+---
+
+## Inertia.js
+
+Server-side routing + client-side rendering without a separate API.
+
+### Laravel Side
+
+```php
+// Controller returns Inertia response
+class PostController extends Controller
+{
+    public function index(): Response
+    {
+        return Inertia::render('Posts/Index', [
+            'posts'  => PostResource::collection(Post::paginate(15)),
+            'filters' => request()->only(['search', 'status']),
+        ]);
+    }
+
+    // Lazy-loaded props (only sent when explicitly requested)
+    public function show(Post $post): Response
+    {
+        return Inertia::render('Posts/Show', [
+            'post'     => PostResource::make($post),
+            'comments' => Inertia::lazy(fn() => CommentResource::collection($post->comments()->paginate(20))),
+        ]);
+    }
+
+    // Redirect after form submission
+    public function store(StorePostRequest $request): RedirectResponse
+    {
+        $post = Post::create($request->validated() + ['user_id' => auth()->id()]);
+        return redirect()->route('posts.show', $post)->with('success', 'Post created.');
+    }
+}
+
+// Shared data (available on every page)
+// HandleInertiaRequests middleware
+public function share(Request $request): array
+{
+    return [
+        ...parent::share($request),
+        'auth' => [
+            'user' => $request->user()?->only('id', 'name', 'email'),
+        ],
+        'flash' => [
+            'success' => $request->session()->get('success'),
+            'error'   => $request->session()->get('error'),
+        ],
+    ];
+}
+```
+
+### Vue Side (Inertia + Vue 3)
+
+```vue
+<!-- resources/js/Pages/Posts/Index.vue -->
+<script setup>
+import { ref } from 'vue'
+import { router, useForm, usePage } from '@inertiajs/vue3'
+
+const props = defineProps({
+    posts: Object,
+    filters: Object,
+})
+
+const page = usePage()
+const auth = page.props.auth  // shared data
+
+// Form helper
+const form = useForm({
+    title: '',
+    body: '',
+})
+
+function submit() {
+    form.post('/posts', {
+        onSuccess: () => form.reset(),
+    })
+}
+
+// Partial reloads (only refresh 'posts' prop)
+function search(query) {
+    router.get('/posts', { search: query }, {
+        preserveState: true,
+        only: ['posts'],
+    })
+}
+</script>
+
+<template>
+    <div>
+        <div v-for="post in posts.data" :key="post.id">
+            <Link :href="`/posts/${post.id}`">{{ post.title }}</Link>
+        </div>
+
+        <!-- Inertia pagination links -->
+        <Pagination :links="posts.links" />
+    </div>
+</template>
+```
+
+---
+
+## Blade Directives and Helpers
+
+### Authorization Directives
+
+```blade
+@auth
+    <a href="/dashboard">Dashboard</a>
+@endauth
+
+@guest
+    <a href="/login">Login</a>
+@endguest
+
+@can('update', $post)
+    <a href="{{ route('posts.edit', $post) }}">Edit</a>
+@endcan
+
+@cannot('delete', $post)
+    <p>You cannot delete this post.</p>
+@endcannot
+
+@role('admin')   {{-- if using spatie/laravel-permission --}}
+    <a href="/admin">Admin Panel</a>
+@endrole
+```
+
+### Looping Directives
+
+```blade
+@forelse($posts as $post)
+    <article>{{ $post->title }}</article>
+@empty
+    <p>No posts found.</p>
+@endforelse
+
+{{-- Loop variable --}}
+@foreach($items as $item)
+    @if($loop->first) <ul> @endif
+    <li class="{{ $loop->even ? 'even' : 'odd' }}">
+        {{ $loop->iteration }}. {{ $item->name }}
+    </li>
+    @if($loop->last) </ul> @endif
+@endforeach
+```
+
+### Custom Directives
+
+```php
+// AppServiceProvider::boot()
+Blade::directive('currency', function ($expression) {
+    return "<?php echo '$' . number_format({$expression}, 2); ?>";
+});
+
+Blade::if('env', function (string $environment) {
+    return app()->environment($environment);
+});
+// Usage: @env('production') ... @endenv
+```

+ 835 - 0
skills/laravel-ops/references/eloquent-queries.md

@@ -0,0 +1,835 @@
+# Eloquent Queries Reference
+
+Deep-dive reference for Eloquent ORM: relationships, query builder, scopes, accessors, mutators, events, soft deletes, pagination, performance, collections, and factories.
+
+---
+
+## Relationships
+
+### hasOne
+
+```php
+// User hasOne Profile
+class User extends Model
+{
+    public function profile(): HasOne
+    {
+        return $this->hasOne(Profile::class);
+        // Convention: profiles.user_id
+        // Custom: $this->hasOne(Profile::class, 'foreign_key', 'local_key')
+    }
+}
+
+// Usage
+$profile = $user->profile;                    // lazy load
+$user = User::with('profile')->find(1);       // eager load
+$user->profile()->create(['bio' => '...']);   // create via relationship
+```
+
+### hasMany
+
+```php
+class User extends Model
+{
+    public function posts(): HasMany
+    {
+        return $this->hasMany(Post::class);
+    }
+}
+
+// Usage
+$posts = $user->posts;                        // Collection
+$posts = $user->posts()->published()->get();  // chained query
+$user->posts()->createMany([
+    ['title' => 'First'],
+    ['title' => 'Second'],
+]);
+```
+
+### belongsTo
+
+```php
+class Post extends Model
+{
+    public function author(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'user_id'); // explicit FK
+    }
+}
+
+// Avoid null when accessing author before saving
+$post->author()->associate($user); // sets user_id
+$post->save();
+
+// Dissociate (set FK to null)
+$post->author()->dissociate();
+$post->save();
+```
+
+### belongsToMany (many-to-many with pivot)
+
+```php
+class User extends Model
+{
+    public function roles(): BelongsToMany
+    {
+        return $this->belongsToMany(Role::class)
+                    ->withPivot('assigned_at', 'assigned_by')
+                    ->withTimestamps()
+                    ->using(RoleUser::class); // custom pivot model
+    }
+}
+
+// Pivot model with extra attributes
+class RoleUser extends Pivot
+{
+    protected $casts = [
+        'assigned_at' => 'datetime',
+    ];
+}
+
+// Attach / detach / sync
+$user->roles()->attach($roleId, ['assigned_by' => auth()->id()]);
+$user->roles()->detach($roleId);
+$user->roles()->sync([1, 2, 3]);                    // replaces all
+$user->roles()->syncWithoutDetaching([4, 5]);       // additive only
+$user->roles()->toggle([1, 2]);                     // attach if not, detach if yes
+
+// Querying pivot
+$user->roles()->wherePivot('assigned_by', $userId)->get();
+
+// Access pivot in result
+foreach ($user->roles as $role) {
+    echo $role->pivot->assigned_at;
+}
+```
+
+### hasManyThrough
+
+```php
+// Country → User → Post (access posts through users)
+class Country extends Model
+{
+    public function posts(): HasManyThrough
+    {
+        return $this->hasManyThrough(
+            Post::class,  // final model
+            User::class,  // intermediate model
+            'country_id', // FK on users table
+            'user_id',    // FK on posts table
+            'id',         // local key on countries
+            'id'          // local key on users
+        );
+    }
+}
+```
+
+### hasOneThrough
+
+```php
+// Mechanic → Car → CarOwner (through single intermediary)
+class Mechanic extends Model
+{
+    public function carOwner(): HasOneThrough
+    {
+        return $this->hasOneThrough(Owner::class, Car::class);
+    }
+}
+```
+
+### Polymorphic: morphTo / morphMany
+
+```php
+// Comment can belong to Post or Video
+class Comment extends Model
+{
+    public function commentable(): MorphTo
+    {
+        return $this->morphTo(); // uses commentable_type + commentable_id
+    }
+}
+
+class Post extends Model
+{
+    public function comments(): MorphMany
+    {
+        return $this->morphMany(Comment::class, 'commentable');
+    }
+}
+
+class Video extends Model
+{
+    public function comments(): MorphMany
+    {
+        return $this->morphMany(Comment::class, 'commentable');
+    }
+}
+
+// Usage
+$post->comments()->create(['body' => 'Great post!']);
+$comment->commentable; // returns Post or Video instance
+
+// Morph map (cleaner DB values)
+// AppServiceProvider::boot()
+Relation::morphMap([
+    'post'  => Post::class,
+    'video' => Video::class,
+]);
+```
+
+### morphToMany (polymorphic many-to-many)
+
+```php
+// Post and Video can have many Tags
+class Post extends Model
+{
+    public function tags(): MorphToMany
+    {
+        return $this->morphToMany(Tag::class, 'taggable');
+        // pivot: taggables (taggable_id, taggable_type, tag_id)
+    }
+}
+
+class Tag extends Model
+{
+    public function posts(): MorphedByMany
+    {
+        return $this->morphedByMany(Post::class, 'taggable');
+    }
+}
+```
+
+---
+
+## Query Builder
+
+### Basic Constraints
+
+```php
+// Where clauses
+User::where('status', 'active')
+    ->where('age', '>=', 18)
+    ->orWhere('is_admin', true)
+    ->get();
+
+// whereIn / whereNotIn
+Post::whereIn('status', ['published', 'featured'])->get();
+Post::whereNotIn('user_id', [1, 2, 3])->get();
+
+// whereNull / whereNotNull
+User::whereNull('deleted_at')->get();
+User::whereNotNull('email_verified_at')->get();
+
+// whereBetween
+Order::whereBetween('total', [100, 500])->get();
+
+// whereDate / whereYear / whereMonth / whereDay
+Post::whereDate('created_at', '2024-01-15')->get();
+Post::whereYear('created_at', 2024)->get();
+
+// whereColumn (compare two columns)
+Order::whereColumn('shipped_at', '>', 'ordered_at')->get();
+```
+
+### Relationship Constraints
+
+```php
+// whereHas: filter models with related models matching condition
+Post::whereHas('comments', function (Builder $query) {
+    $query->where('approved', true);
+})->get();
+
+// whereDoesntHave
+Post::whereDoesntHave('comments')->get(); // posts with no comments
+
+// withWhereHas: eager load + constrain simultaneously
+Post::withWhereHas('comments', fn($q) => $q->approved())->get();
+
+// whereHas with count
+Post::whereHas('comments', fn($q) => $q, '>=', 5)->get(); // at least 5 comments
+```
+
+### Subqueries
+
+```php
+// Select subquery
+$users = User::addSelect([
+    'last_login_at' => Login::select('created_at')
+        ->whereColumn('user_id', 'users.id')
+        ->latest()
+        ->limit(1),
+])->get();
+
+// orderBy subquery
+$users = User::orderByDesc(
+    Login::select('created_at')
+        ->whereColumn('user_id', 'users.id')
+        ->latest()
+        ->limit(1)
+)->get();
+
+// From subquery
+$orders = DB::table(function (Builder $query) {
+    $query->from('orders')->where('status', 'shipped');
+}, 'shipped_orders')->get();
+```
+
+### Raw Expressions
+
+```php
+// selectRaw
+User::selectRaw('COUNT(*) as total, DATE(created_at) as date')
+    ->groupByRaw('DATE(created_at)')
+    ->get();
+
+// whereRaw
+User::whereRaw('LOWER(email) = ?', [strtolower($email)])->first();
+
+// orderByRaw
+Post::orderByRaw('FIELD(status, "featured", "published", "draft")')->get();
+
+// havingRaw
+User::selectRaw('country, COUNT(*) as total')
+    ->groupBy('country')
+    ->havingRaw('COUNT(*) > ?', [100])
+    ->get();
+```
+
+---
+
+## Query Scopes
+
+### Local Scopes
+
+```php
+class Post extends Model
+{
+    // Constraint scope
+    public function scopePublished(Builder $query): void
+    {
+        $query->where('status', 'published')
+              ->whereNotNull('published_at');
+    }
+
+    // Dynamic scope with parameter
+    public function scopeByStatus(Builder $query, string $status): void
+    {
+        $query->where('status', $status);
+    }
+
+    // Scope with optional parameter
+    public function scopeRecent(Builder $query, int $days = 7): void
+    {
+        $query->where('created_at', '>=', now()->subDays($days));
+    }
+}
+
+// Usage (chaining scopes)
+Post::published()->recent(30)->orderByDesc('published_at')->paginate(15);
+Post::byStatus('draft')->get();
+```
+
+### Global Scopes
+
+```php
+// Define scope class
+class ActiveScope implements Scope
+{
+    public function apply(Builder $builder, Model $model): void
+    {
+        $builder->where('active', true);
+    }
+}
+
+// Apply globally (in model boot or via attribute in Laravel 11+)
+class User extends Model
+{
+    protected static function booted(): void
+    {
+        static::addGlobalScope(new ActiveScope());
+        // Or anonymous: static::addGlobalScope('active', fn(Builder $b) => $b->where('active', true));
+    }
+}
+
+// Removing global scope for specific query
+User::withoutGlobalScope(ActiveScope::class)->get();
+User::withoutGlobalScope('active')->get();
+User::withoutGlobalScopes()->get(); // remove all
+```
+
+---
+
+## Accessors and Mutators (Laravel 11+ Attribute Class)
+
+```php
+use Illuminate\Database\Eloquent\Casts\Attribute;
+
+class User extends Model
+{
+    // Accessor only
+    protected function fullName(): Attribute
+    {
+        return Attribute::make(
+            get: fn() => "{$this->first_name} {$this->last_name}",
+        );
+    }
+
+    // Mutator only
+    protected function password(): Attribute
+    {
+        return Attribute::make(
+            set: fn(string $value) => bcrypt($value),
+        );
+    }
+
+    // Accessor + Mutator
+    protected function name(): Attribute
+    {
+        return Attribute::make(
+            get: fn(string $value) => ucfirst($value),
+            set: fn(string $value) => strtolower($value),
+        )->withoutObjectCaching(); // recompute each access
+    }
+}
+
+// Usage
+$user->full_name;       // "John Doe"
+$user->password = 'secret'; // automatically hashed
+```
+
+### Built-in Casts
+
+```php
+protected $casts = [
+    'is_admin'       => 'boolean',
+    'score'          => 'float',
+    'metadata'       => 'array',          // JSON column ↔ array
+    'preferences'    => 'collection',      // JSON ↔ Collection
+    'settings'       => AsArrayObject::class,    // JSON ↔ ArrayObject (mutable)
+    'options'        => AsCollection::class,     // JSON ↔ Collection (mutable)
+    'secret'         => 'encrypted',       // transparent encryption
+    'secret_array'   => 'encrypted:array', // encrypted JSON
+    'birthday'       => 'date',            // Carbon without time
+    'published_at'   => 'datetime',        // Carbon with time
+    'status'         => PostStatus::class, // PHP 8.1 enum
+];
+```
+
+### Enum Casting (PHP 8.1+)
+
+```php
+enum PostStatus: string
+{
+    case Draft     = 'draft';
+    case Published = 'published';
+    case Archived  = 'archived';
+}
+
+class Post extends Model
+{
+    protected $casts = [
+        'status' => PostStatus::class,
+    ];
+}
+
+// Usage
+$post->status = PostStatus::Published; // or 'published'
+$post->status->label();                // if you add methods to enum
+Post::where('status', PostStatus::Published)->get();
+```
+
+---
+
+## Eloquent Events
+
+### Model Lifecycle Events
+
+| Event | Fires When |
+|-------|-----------|
+| `creating` | Before INSERT (can cancel with false) |
+| `created` | After INSERT |
+| `updating` | Before UPDATE (can cancel with false) |
+| `updated` | After UPDATE |
+| `saving` | Before INSERT or UPDATE |
+| `saved` | After INSERT or UPDATE |
+| `deleting` | Before DELETE (can cancel with false) |
+| `deleted` | After DELETE |
+| `restoring` | Before restore (soft delete) |
+| `restored` | After restore |
+| `retrieved` | After SELECT (heavy use discouraged) |
+
+### Registering Listeners
+
+```php
+// Option 1: $dispatchesEvents on model
+class Post extends Model
+{
+    protected $dispatchesEvents = [
+        'created'  => PostCreated::class,
+        'deleted'  => PostDeleted::class,
+    ];
+}
+
+// Option 2: boot() method (for closures)
+class Post extends Model
+{
+    protected static function booted(): void
+    {
+        static::creating(function (Post $post) {
+            $post->slug = Str::slug($post->title);
+        });
+
+        static::deleting(function (Post $post) {
+            $post->comments()->delete(); // cascade via Eloquent
+        });
+    }
+}
+```
+
+### Observer Classes
+
+```php
+// php artisan make:observer PostObserver --model=Post
+
+class PostObserver
+{
+    public function creating(Post $post): void
+    {
+        $post->slug = Str::slug($post->title);
+        $post->user_id ??= auth()->id();
+    }
+
+    public function created(Post $post): void
+    {
+        Cache::tags('posts')->flush();
+    }
+
+    public function updated(Post $post): void
+    {
+        Cache::tags('posts')->flush();
+    }
+
+    public function deleted(Post $post): void
+    {
+        $post->comments()->delete();
+    }
+}
+
+// Register in AppServiceProvider::boot()
+Post::observe(PostObserver::class);
+
+// Silence observer for bulk operations
+Post::withoutObservers(function () {
+    Post::query()->update(['featured' => false]);
+});
+```
+
+---
+
+## Soft Deletes
+
+```php
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+class Post extends Model
+{
+    use SoftDeletes; // adds deleted_at column
+}
+
+// Migration
+Schema::table('posts', function (Blueprint $table) {
+    $table->softDeletes(); // nullable deleted_at timestamp
+});
+
+// Usage
+$post->delete();           // sets deleted_at (soft delete)
+$post->forceDelete();      // permanent DELETE
+
+// Querying
+Post::all();               // excludes soft-deleted (default)
+Post::withTrashed()->get(); // includes soft-deleted
+Post::onlyTrashed()->get(); // only soft-deleted
+
+// Restore
+Post::withTrashed()->find($id)->restore();
+Post::withTrashed()->where('user_id', $userId)->restore();
+
+// Check state
+$post->trashed();          // bool
+
+// Route model binding includes soft-deleted
+Route::get('/posts/{post}', [PostController::class, 'show'])
+    ->withTrashed();
+```
+
+---
+
+## Pagination
+
+| Method | Returns | Use When |
+|--------|---------|----------|
+| `paginate(15)` | `LengthAwarePaginator` | Need total count and last page |
+| `simplePaginate(15)` | `Paginator` | Large datasets, just next/prev needed |
+| `cursorPaginate(15)` | `CursorPaginator` | Huge datasets, consistent performance |
+
+```php
+// Standard pagination (requires COUNT query)
+$posts = Post::published()->paginate(15);
+// Blade: {{ $posts->links() }}
+
+// Simple pagination (no COUNT, just LIMIT+1)
+$posts = Post::published()->simplePaginate(15);
+
+// Cursor pagination (keyset pagination - best for infinite scroll)
+$posts = Post::orderBy('id')->cursorPaginate(15);
+// URL: /posts?cursor=eyJpZCI6MTAwfQ
+
+// JSON API response
+return PostResource::collection($posts); // preserves pagination meta
+
+// Manual pagination
+$total = Post::count();
+$posts = Post::skip($offset)->take($perPage)->get();
+$paginator = new LengthAwarePaginator($posts, $total, $perPage, $currentPage);
+```
+
+---
+
+## Performance: Chunking and Lazy Loading
+
+### When to Use Each
+
+| Method | Memory | Speed | Use When |
+|--------|--------|-------|----------|
+| `get()` | All records | Fast | < 10k records |
+| `chunk(1000)` | Chunk size | Moderate | Large datasets, mutations |
+| `chunkById(1000)` | Chunk size | More stable | Large datasets (avoids offset drift) |
+| `lazy()` | Low (generator) | Fast | Read-only iteration |
+| `cursor()` | Very low | Fastest | Streaming large result sets |
+
+```php
+// chunk - runs separate queries per chunk
+Post::where('status', 'draft')->chunk(500, function (Collection $posts) {
+    foreach ($posts as $post) {
+        $post->update(['status' => 'published']);
+    }
+});
+
+// chunkById - stable cursor-based chunking (avoids missing rows when deleting)
+Post::orderBy('id')->chunkById(500, function (Collection $posts) {
+    $posts->each->delete();
+});
+
+// lazy - PHP generator, single query with cursor
+foreach (Post::lazy(500) as $post) {
+    ProcessPost::dispatch($post);
+}
+
+// cursor - yields one model at a time, minimal memory
+foreach (Post::cursor() as $post) {
+    echo $post->title . PHP_EOL;
+}
+```
+
+### Query Logging and Debugging
+
+```php
+// Log all queries (AppServiceProvider::boot)
+DB::listen(function (QueryExecuted $query) {
+    Log::channel('queries')->info($query->sql, [
+        'bindings' => $query->bindings,
+        'time'     => $query->time,
+    ]);
+});
+
+// Explain a query
+$posts = Post::with('comments')->where('status', 'published');
+dd($posts->explain()); // EXPLAIN output
+
+// Count queries executed (testing)
+DB::enableQueryLog();
+// ... run code ...
+$queries = DB::getQueryLog();
+expect($queries)->toHaveCount(2); // assert no N+1
+DB::disableQueryLog();
+
+// Prevent lazy loading in development
+Model::preventLazyLoading(! app()->isProduction());
+```
+
+---
+
+## Collections
+
+Eloquent returns `Illuminate\Database\Eloquent\Collection` (extends base Collection).
+
+```php
+$users = User::all();
+
+// Transformation
+$names      = $users->pluck('name');                    // Collection of names
+$names      = $users->pluck('name', 'id');              // ['id' => 'name'] keyed
+$active     = $users->filter(fn($u) => $u->is_active);
+$admins     = $users->where('role', 'admin');
+$mapped     = $users->map(fn($u) => ['id' => $u->id, 'email' => $u->email]);
+$grouped    = $users->groupBy('country');               // keyed Collection of Collections
+$sorted     = $users->sortBy('name');
+$sorted     = $users->sortByDesc(fn($u) => $u->posts_count);
+
+// Aggregation
+$total      = $users->sum('balance');
+$avg        = $users->avg('score');
+$max        = $users->max('score');
+$count      = $users->count();
+$first      = $users->first(fn($u) => $u->is_admin);
+
+// Unique / diff / intersect
+$unique     = $users->unique('email');
+$diff       = $users->diff($otherUsers);
+
+// Collection to array/JSON
+$array      = $users->toArray();
+$json       = $users->toJson();
+
+// Reduce
+$total = $users->reduce(fn($carry, $user) => $carry + $user->balance, 0);
+
+// Eloquent-specific collection methods
+$users->find(1);                                        // find by PK
+$users->load('posts');                                  // eager load on collection
+$users->modelKeys();                                    // array of primary keys
+$users->contains($user);                                // check membership
+$users->diff($otherUsers);                              // by PK comparison
+
+// Lazy collections (memory efficient)
+User::lazy()->filter(fn($u) => $u->is_active)->each(fn($u) => ProcessUser::dispatch($u));
+```
+
+---
+
+## Factories
+
+```php
+// database/factories/PostFactory.php
+class PostFactory extends Factory
+{
+    protected $model = Post::class;
+
+    public function definition(): array
+    {
+        return [
+            'user_id'      => User::factory(),         // auto-create related
+            'title'        => $this->faker->sentence(),
+            'slug'         => $this->faker->unique()->slug(),
+            'body'         => $this->faker->paragraphs(3, true),
+            'status'       => 'published',
+            'published_at' => $this->faker->dateTimeBetween('-1 year'),
+        ];
+    }
+
+    // States - modifiers
+    public function draft(): static
+    {
+        return $this->state(['status' => 'draft', 'published_at' => null]);
+    }
+
+    public function featured(): static
+    {
+        return $this->state(['status' => 'featured']);
+    }
+
+    public function withTags(int $count = 3): static
+    {
+        return $this->afterCreating(function (Post $post) use ($count) {
+            $post->tags()->attach(Tag::factory()->count($count)->create());
+        });
+    }
+
+    // Sequence - vary per-record
+    public function configure(): static
+    {
+        return $this->sequence(
+            ['status' => 'draft'],
+            ['status' => 'published'],
+            ['status' => 'archived'],
+        );
+    }
+}
+
+// Usage in tests or seeders
+Post::factory()->create();                              // single
+Post::factory()->count(10)->create();                   // 10 records
+Post::factory()->draft()->create();                     // apply state
+Post::factory()->featured()->withTags(5)->create();     // chain states
+Post::factory()->for(User::factory()->admin())->create(); // explicit relationship
+Post::factory()->has(Comment::factory()->count(3))->create(); // hasMany
+Post::factory()->hasComments(3)->create();              // magic has method
+
+// In-memory (not persisted)
+Post::factory()->make();
+Post::factory()->makeMany(5);
+
+// Sequences
+Post::factory()->count(3)->sequence(
+    ['status' => 'draft'],
+    ['status' => 'published'],
+    ['status' => 'archived'],
+)->create();
+
+// afterCreating callback
+Post::factory()->afterCreating(function (Post $post) {
+    $post->searchIndex()->create(['content' => $post->body]);
+})->create();
+```
+
+---
+
+## Advanced Patterns
+
+### Subquery Selects for Aggregates (avoid N+1)
+
+```php
+// Instead of: $users->each(fn($u) => $u->posts->count())
+// Do this:
+$users = User::addSelect([
+    'posts_count' => Post::selectRaw('COUNT(*)')
+        ->whereColumn('user_id', 'users.id'),
+    'last_post_at' => Post::select('created_at')
+        ->whereColumn('user_id', 'users.id')
+        ->latest()
+        ->limit(1),
+])->get();
+```
+
+### Upsert
+
+```php
+// Single upsert
+User::updateOrCreate(
+    ['email' => 'user@example.com'],             // find by
+    ['name' => 'John', 'role' => 'admin']        // update/create with
+);
+
+// Bulk upsert (one query)
+Post::upsert(
+    [
+        ['id' => 1, 'title' => 'Updated', 'slug' => 'updated'],
+        ['id' => 2, 'title' => 'New Post', 'slug' => 'new-post'],
+    ],
+    uniqueBy: ['slug'],                          // conflict column(s)
+    update: ['title']                            // columns to update on conflict
+);
+```
+
+### Locking for Concurrency
+
+```php
+// Shared lock (read lock - prevent other writes)
+$order = Order::where('id', $id)->sharedLock()->first();
+
+// Exclusive lock (write lock - prevent other reads and writes)
+DB::transaction(function () use ($orderId) {
+    $order = Order::where('id', $orderId)->lockForUpdate()->first();
+    $order->decrement('quantity');
+});
+```

+ 1019 - 0
skills/laravel-ops/references/testing-auth.md

@@ -0,0 +1,1019 @@
+# Testing and Authentication Reference
+
+Deep-dive reference for PHPUnit/Pest testing, Sanctum, Fortify, policies, form requests, and browser testing with Dusk.
+
+---
+
+## PHPUnit Setup
+
+### phpunit.xml
+
+```xml
+<?xml version="1.0" encoding="UTF-8"?>
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
+         bootstrap="vendor/autoload.php"
+         colors="true">
+    <testsuites>
+        <testsuite name="Unit">
+            <directory suffix="Test.php">./tests/Unit</directory>
+        </testsuite>
+        <testsuite name="Feature">
+            <directory suffix="Test.php">./tests/Feature</directory>
+        </testsuite>
+    </testsuites>
+
+    <source>
+        <include>
+            <directory suffix=".php">./app</directory>
+        </include>
+    </source>
+
+    <php>
+        <env name="APP_ENV" value="testing"/>
+        <env name="APP_KEY" value="base64:test-key-32-chars-here-padded"/>
+        <env name="CACHE_STORE" value="array"/>
+        <env name="DB_CONNECTION" value="sqlite"/>
+        <env name="DB_DATABASE" value=":memory:"/>
+        <env name="MAIL_MAILER" value="array"/>
+        <env name="QUEUE_CONNECTION" value="sync"/>
+        <env name="SESSION_DRIVER" value="array"/>
+    </php>
+</phpunit>
+```
+
+### Test Databases
+
+```php
+// Option 1: SQLite in-memory (fastest)
+// .env.testing
+DB_CONNECTION=sqlite
+DB_DATABASE=:memory:
+
+// Option 2: Separate MySQL test database
+DB_CONNECTION=mysql
+DB_DATABASE=app_testing
+
+// Option 3: Per-test transaction rollback (fastest for MySQL)
+use Illuminate\Foundation\Testing\DatabaseTransactions;
+
+// Option 4: Migrate fresh per test class (safest, slowest)
+use Illuminate\Foundation\Testing\RefreshDatabase;
+```
+
+---
+
+## Pest PHP (Preferred in Laravel 11+)
+
+### Project Setup
+
+```bash
+composer require pestphp/pest pestphp/pest-plugin-laravel --dev
+php artisan pest:install
+```
+
+### File Structure and Syntax
+
+```php
+// tests/Feature/PostTest.php
+use App\Models\{Post, User};
+use Illuminate\Foundation\Testing\RefreshDatabase;
+
+uses(RefreshDatabase::class);
+
+// Group related tests
+describe('Post creation', function () {
+    beforeEach(function () {
+        $this->user = User::factory()->create();
+        $this->actingAs($this->user);
+    });
+
+    it('creates a post with valid data', function () {
+        $response = $this->post('/posts', [
+            'title' => 'My First Post',
+            'body'  => 'Post content here.',
+        ]);
+
+        $response->assertRedirect();
+        $this->assertDatabaseHas('posts', ['title' => 'My First Post']);
+    });
+
+    it('requires a title', function () {
+        $response = $this->post('/posts', ['body' => 'Content']);
+        $response->assertInvalid(['title']);
+    });
+
+    it('is pending future implementation')->todo();
+});
+
+// Top-level tests
+test('guests cannot create posts', function () {
+    $this->post('/posts', ['title' => 'Test'])->assertRedirect('/login');
+});
+```
+
+### Pest Expectations
+
+```php
+// Chained expectations
+expect($value)
+    ->toBeTrue()
+    ->not->toBeNull()
+    ->toEqual('expected')
+    ->toBeString()
+    ->toHaveCount(3)
+    ->toContain('substring')
+    ->toMatchArray(['key' => 'value'])
+    ->toHaveKey('name')
+    ->toHaveKeys(['id', 'name', 'email'])
+    ->toBeBetween(1, 10)
+    ->toBeGreaterThan(5)
+    ->toBeLessThanOrEqual(100)
+    ->toBeInstanceOf(User::class)
+    ->toBeNull()
+    ->toBeEmpty()
+    ->toThrow(InvalidArgumentException::class, 'message');
+
+// Higher-order expectations
+expect([1, 2, 3])->each->toBeInt();
+expect($users)->each->toBeInstanceOf(User::class);
+
+// Expectations on collections
+expect($users)->sequence(
+    fn($user) => $user->name->toBe('Alice'),
+    fn($user) => $user->name->toBe('Bob'),
+);
+```
+
+### Datasets
+
+```php
+it('validates email format', function (string $email, bool $valid) {
+    $response = $this->post('/register', ['email' => $email]);
+
+    if ($valid) {
+        $response->assertValid(['email']);
+    } else {
+        $response->assertInvalid(['email']);
+    }
+})->with([
+    ['valid@example.com', true],
+    ['not-an-email', false],
+    ['missing@', false],
+    ['@nodomain.com', false],
+]);
+
+// Shared datasets
+// tests/Datasets/emails.php
+dataset('invalid_emails', ['not-email', '@nodomain', 'missing@tld']);
+```
+
+### Architectural Testing
+
+```php
+// tests/Architecture/AppTest.php
+arch('controllers do not use Eloquent directly')
+    ->expect('App\Http\Controllers')
+    ->not->toUse(['Illuminate\Database\Eloquent\Model']);
+
+arch('actions are invokable')
+    ->expect('App\Actions')
+    ->toBeClasses()
+    ->toHaveSuffix('Action');
+
+arch('models extend Eloquent')
+    ->expect('App\Models')
+    ->toExtend('Illuminate\Database\Eloquent\Model');
+
+arch('no debug functions in production code')
+    ->expect('App')
+    ->not->toUse(['dd', 'dump', 'ray', 'var_dump']);
+```
+
+---
+
+## HTTP Tests
+
+### Basic HTTP Testing
+
+```php
+// GET requests
+$response = $this->get('/posts');
+$response = $this->getJson('/api/posts');           // sets Accept: application/json
+
+// POST / PUT / PATCH / DELETE
+$response = $this->post('/posts', $data);
+$response = $this->postJson('/api/posts', $data);
+$response = $this->put('/posts/1', $data);
+$response = $this->patch('/posts/1', ['status' => 'published']);
+$response = $this->delete('/posts/1');
+
+// With headers
+$response = $this->withHeaders(['X-Custom-Header' => 'value'])->get('/api/data');
+
+// With cookies
+$response = $this->withCookie('token', 'abc')->get('/dashboard');
+
+// Follow redirects
+$response = $this->followingRedirects()->post('/posts', $data);
+```
+
+### Response Assertions
+
+```php
+// Status codes
+$response->assertOk();                                    // 200
+$response->assertCreated();                               // 201
+$response->assertAccepted();                              // 202
+$response->assertNoContent();                             // 204
+$response->assertMovedPermanently();                      // 301
+$response->assertFound();                                 // 302
+$response->assertNotModified();                           // 304
+$response->assertBadRequest();                            // 400
+$response->assertUnauthorized();                          // 401
+$response->assertPaymentRequired();                       // 402
+$response->assertForbidden();                             // 403
+$response->assertNotFound();                              // 404
+$response->assertMethodNotAllowed();                      // 405
+$response->assertUnprocessable();                         // 422
+$response->assertTooManyRequests();                       // 429
+$response->assertServerError();                           // 500
+$response->assertStatus(418);                             // custom
+
+// Redirect
+$response->assertRedirect('/home');
+$response->assertRedirectToRoute('dashboard');
+$response->assertRedirectContains('/orders');
+
+// View
+$response->assertViewIs('posts.index');
+$response->assertViewHas('posts');
+$response->assertViewHas('user', fn($user) => $user->id === 1);
+$response->assertSee('Hello World');
+$response->assertSeeText('Hello World');                  // strips HTML
+$response->assertDontSee('Error');
+
+// JSON
+$response->assertJson(['status' => 'ok', 'data' => ['id' => 1]]);
+$response->assertJsonFragment(['email' => 'user@example.com']);
+$response->assertJsonPath('data.user.name', 'John');
+$response->assertJsonPath('data.*.id', [1, 2, 3]);
+$response->assertJsonCount(3, 'data');
+$response->assertJsonStructure([
+    'data' => [
+        '*' => ['id', 'title', 'created_at'],
+    ],
+    'meta' => ['total', 'per_page'],
+]);
+$response->assertJsonMissing(['password', 'remember_token']);
+$response->assertExactJson(['key' => 'value']);           // exact match
+
+// Headers and cookies
+$response->assertHeader('Content-Type', 'application/json');
+$response->assertCookie('session');
+$response->assertCookieMissing('auth_token');
+
+// Session
+$response->assertSessionHas('success');
+$response->assertSessionHasErrors(['email', 'password']);
+$response->assertSessionMissing('error');
+
+// Validation errors
+$response->assertValid(['name', 'email']);
+$response->assertInvalid(['email' => 'invalid email format']);
+```
+
+---
+
+## Database Testing
+
+### Traits
+
+```php
+use Illuminate\Foundation\Testing\RefreshDatabase;
+// Migrates fresh for every test class (drops + re-migrates). Slower but safe.
+
+use Illuminate\Foundation\Testing\DatabaseTransactions;
+// Wraps each test in a transaction, rolls back. Fast, but doesn't work with external processes.
+
+use Illuminate\Foundation\Testing\DatabaseMigrations;
+// Migrates before the test suite, rolls back after. Per-file.
+```
+
+### Database Assertions
+
+```php
+$this->assertDatabaseHas('users', [
+    'email' => 'user@example.com',
+    'role'  => 'admin',
+]);
+
+$this->assertDatabaseMissing('users', [
+    'email' => 'deleted@example.com',
+]);
+
+$this->assertDatabaseCount('posts', 5);
+
+$this->assertSoftDeleted('posts', ['id' => $post->id]);
+$this->assertNotSoftDeleted('posts', ['id' => $post->id]);
+
+$this->assertDatabaseEmpty('cache');
+
+// Model-based assertions
+$this->assertModelExists($post);
+$this->assertModelMissing($deletedPost);
+```
+
+### Factory Usage in Tests
+
+```php
+// Create persisted records
+$user = User::factory()->create();
+$user = User::factory()->admin()->create(['name' => 'Override Name']);
+
+// Create without persisting
+$user = User::factory()->make();
+
+// Create multiple
+$users = User::factory()->count(5)->create();
+
+// Create with relationships
+$post = Post::factory()
+    ->for(User::factory()->admin())
+    ->hasComments(3)
+    ->withTags(5)
+    ->create();
+
+// Seed specific data
+$this->seed(RoleSeeder::class);
+$this->seed([RoleSeeder::class, PermissionSeeder::class]);
+```
+
+---
+
+## Mocking Facades
+
+### Mail
+
+```php
+Mail::fake();
+
+$this->post('/checkout', $orderData);
+
+Mail::assertSent(OrderConfirmationMail::class);
+Mail::assertSent(OrderConfirmationMail::class, 1);        // sent exactly once
+Mail::assertSent(OrderConfirmationMail::class, fn($mail) =>
+    $mail->hasTo('customer@example.com') &&
+    $mail->hasSubject('Your Order Confirmation')
+);
+Mail::assertNotSent(RefundMail::class);
+Mail::assertQueued(WeeklyNewsletterMail::class);           // queued, not sent
+Mail::assertNothingSent();
+```
+
+### Notification
+
+```php
+Notification::fake();
+
+$this->post('/orders', $data);
+
+Notification::assertSentTo($user, InvoicePaidNotification::class);
+Notification::assertSentTo($user, InvoicePaidNotification::class, fn($n) =>
+    $n->invoice->id === $invoiceId
+);
+Notification::assertNotSentTo($admin, InvoicePaidNotification::class);
+Notification::assertCount(2);
+Notification::assertNothingSent();
+
+// On-demand notifications
+Notification::assertSentOnDemand(AlertNotification::class, fn($n, $routes) =>
+    $routes->hasRoute('mail', 'ops@example.com')
+);
+```
+
+### Event
+
+```php
+Event::fake();
+// Or fake only specific events:
+Event::fake([OrderPlaced::class, PaymentProcessed::class]);
+
+$this->post('/orders', $data);
+
+Event::assertDispatched(OrderPlaced::class);
+Event::assertDispatched(OrderPlaced::class, fn($e) => $e->order->id === $orderId);
+Event::assertDispatchedTimes(StockUpdated::class, 3);
+Event::assertNotDispatched(OrderCancelled::class);
+Event::assertListening(OrderPlaced::class, SendOrderConfirmation::class);
+Event::assertNothingDispatched();
+```
+
+### Queue / Bus
+
+```php
+Queue::fake();
+
+$this->post('/upload', $fileData);
+
+Queue::assertPushed(ProcessUpload::class);
+Queue::assertPushed(ProcessUpload::class, fn($job) => $job->filename === 'test.csv');
+Queue::assertPushedOn('imports', ProcessUpload::class);
+Queue::assertNotPushed(NotifyAdmin::class);
+Queue::assertCount(2);
+Queue::assertNothingPushed();
+
+// Bus for batches and chains
+Bus::fake();
+Bus::assertChained([ValidateData::class, ProcessData::class, NotifyUser::class]);
+Bus::assertBatched(fn($batch) => $batch->jobs->count() === 100);
+```
+
+### HTTP Client
+
+```php
+Http::fake([
+    'api.stripe.com/v1/charges' => Http::response([
+        'id'     => 'ch_123',
+        'status' => 'succeeded',
+    ], 200),
+    'api.sendgrid.com/*' => Http::response(['message' => 'success'], 202),
+    '*' => Http::response('Not mocked', 404),  // catch-all
+]);
+
+// Simulate failure
+Http::fake(['api.stripe.com/*' => Http::response(['error' => 'declined'], 402)]);
+
+// Sequence of responses
+Http::fake([
+    'api.example.com/*' => Http::sequence()
+        ->push(['data' => []],  200)
+        ->push(['data' => [1]], 200)
+        ->pushStatus(429),      // rate limit on 3rd call
+]);
+
+// Assert requests were made
+Http::assertSent(fn($request) =>
+    $request->url() === 'https://api.stripe.com/v1/charges' &&
+    $request['amount'] === 2000
+);
+Http::assertSentCount(3);
+Http::assertNotSent(fn($request) => str_contains($request->url(), 'sendgrid'));
+```
+
+### Storage
+
+```php
+Storage::fake('s3');
+
+$this->post('/avatars', ['photo' => UploadedFile::fake()->image('photo.jpg')]);
+
+Storage::disk('s3')->assertExists('avatars/photo.jpg');
+Storage::disk('s3')->assertMissing('avatars/old.jpg');
+```
+
+---
+
+## Sanctum Authentication
+
+### API Token Authentication
+
+```php
+// Installation
+composer require laravel/sanctum
+php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
+php artisan migrate
+
+// User model
+use Laravel\Sanctum\HasApiTokens;
+class User extends Authenticatable { use HasApiTokens; }
+
+// Issue token (login endpoint)
+$token = $user->createToken('mobile-app', ['orders:read', 'orders:write']);
+return response()->json(['token' => $token->plainTextToken]);
+
+// Check abilities
+$user->tokenCan('orders:read');   // bool
+$user->currentAccessToken();      // PersonalAccessToken model
+
+// Token expiration (config/sanctum.php)
+'expiration' => 60 * 24 * 7,      // 7 days in minutes
+
+// Revoke tokens
+$user->tokens()->delete();         // all tokens
+$user->currentAccessToken()->delete(); // current only
+```
+
+### Testing with Sanctum
+
+```php
+use Laravel\Sanctum\Sanctum;
+
+// Authenticate as user (no real token needed)
+Sanctum::actingAs($user);
+Sanctum::actingAs($user, ['orders:read', 'orders:write']); // with abilities
+
+// Feature test examples
+it('returns orders for authenticated user', function () {
+    Sanctum::actingAs(User::factory()->create(), ['orders:read']);
+    Order::factory()->count(3)->for(auth()->user())->create();
+
+    $this->getJson('/api/orders')
+         ->assertOk()
+         ->assertJsonCount(3, 'data');
+});
+
+it('rejects requests without valid token', function () {
+    $this->getJson('/api/orders')->assertUnauthorized();
+});
+
+it('enforces token abilities', function () {
+    Sanctum::actingAs(User::factory()->create(), ['orders:read']); // no write ability
+    $this->postJson('/api/orders', $data)->assertForbidden();
+});
+```
+
+### SPA Authentication (Cookie-based)
+
+```php
+// Frontend must first hit GET /sanctum/csrf-cookie
+// Then POST /login with credentials
+// Subsequent requests use session cookie + X-XSRF-TOKEN header
+
+// config/sanctum.php
+'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost,localhost:3000')),
+
+// routes/api.php
+Route::middleware('auth:sanctum')->get('/user', fn(Request $request) => $request->user());
+
+// CORS (config/cors.php)
+'paths'             => ['api/*', 'sanctum/csrf-cookie'],
+'allowed_origins'   => ['http://localhost:3000'],
+'supports_credentials' => true,
+```
+
+---
+
+## Fortify (Headless Authentication)
+
+```bash
+composer require laravel/fortify
+php artisan vendor:publish --provider="Laravel\Fortify\FortifyServiceProvider"
+php artisan migrate
+```
+
+### Configuration
+
+```php
+// config/fortify.php
+'features' => [
+    Features::registration(),
+    Features::resetPasswords(),
+    Features::emailVerification(),
+    Features::updateProfileInformation(),
+    Features::updatePasswords(),
+    Features::twoFactorAuthentication([
+        'confirm'        => true,
+        'confirmPassword' => true,
+    ]),
+],
+```
+
+### Customizing Actions
+
+```php
+// app/Actions/Fortify/CreateNewUser.php
+class CreateNewUser implements CreatesNewUsers
+{
+    public function create(array $input): User
+    {
+        Validator::make($input, [
+            'name'     => ['required', 'string', 'max:255'],
+            'email'    => ['required', 'email', 'unique:users'],
+            'password' => ['required', Password::defaults(), 'confirmed'],
+        ])->validate();
+
+        return DB::transaction(function () use ($input) {
+            $user = User::create([
+                'name'     => $input['name'],
+                'email'    => $input['email'],
+                'password' => Hash::make($input['password']),
+            ]);
+            $user->assignRole('user');                    // spatie/laravel-permission
+            event(new Registered($user));
+            return $user;
+        });
+    }
+}
+
+// FortifyServiceProvider::boot()
+Fortify::createUsersUsing(CreateNewUser::class);
+Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
+Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
+Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
+```
+
+---
+
+## Policies and Gates
+
+### Defining a Policy
+
+```php
+// php artisan make:policy PostPolicy --model=Post
+class PostPolicy
+{
+    // Gates receive user as first arg (nullable for guests)
+    public function viewAny(?User $user): bool
+    {
+        return true; // anyone can list posts
+    }
+
+    public function view(?User $user, Post $post): bool
+    {
+        return $post->is_published || $user?->id === $post->user_id;
+    }
+
+    public function create(User $user): bool
+    {
+        return $user->hasVerifiedEmail();
+    }
+
+    public function update(User $user, Post $post): bool
+    {
+        return $user->id === $post->user_id || $user->isAdmin();
+    }
+
+    public function delete(User $user, Post $post): bool
+    {
+        return $user->id === $post->user_id || $user->isAdmin();
+    }
+
+    public function restore(User $user, Post $post): bool
+    {
+        return $user->isAdmin();
+    }
+
+    public function forceDelete(User $user, Post $post): bool
+    {
+        return $user->isAdmin();
+    }
+}
+```
+
+### Registering Policies (Laravel 11+ auto-discovery)
+
+```php
+// Auto-discovered if model/policy naming convention followed
+// OR manual registration in AppServiceProvider::boot():
+Gate::policy(Post::class, PostPolicy::class);
+```
+
+### Using Policies
+
+```php
+// Controller
+class PostController extends Controller
+{
+    public function update(Request $request, Post $post): RedirectResponse
+    {
+        $this->authorize('update', $post);
+        // ...
+    }
+
+    // Resource controller - authorize all methods at once
+    public function __construct()
+    {
+        $this->authorizeResource(Post::class, 'post');
+    }
+}
+
+// Route-level middleware
+Route::put('/posts/{post}', [PostController::class, 'update'])
+     ->middleware('can:update,post');
+
+// Blade
+@can('update', $post) ... @endcan
+@cannot('delete', $post) ... @endcannot
+
+// Manual check
+if (Gate::allows('update', $post)) { ... }
+if (Gate::denies('delete', $post)) { abort(403); }
+
+// Before all policy checks (super-admin bypass)
+Gate::before(fn(User $user) => $user->isSuperAdmin() ? true : null);
+```
+
+### Testing Policies
+
+```php
+it('allows post author to update their post', function () {
+    $user = User::factory()->create();
+    $post = Post::factory()->for($user)->create();
+
+    $this->actingAs($user)
+         ->put("/posts/{$post->id}", ['title' => 'Updated'])
+         ->assertOk();
+});
+
+it('prevents non-author from updating post', function () {
+    $author  = User::factory()->create();
+    $visitor = User::factory()->create();
+    $post    = Post::factory()->for($author)->create();
+
+    $this->actingAs($visitor)
+         ->put("/posts/{$post->id}", ['title' => 'Hacked'])
+         ->assertForbidden();
+});
+```
+
+---
+
+## Form Requests
+
+### Request Class
+
+```php
+// php artisan make:request StorePostRequest
+class StorePostRequest extends FormRequest
+{
+    // Who can make this request?
+    public function authorize(): bool
+    {
+        return $this->user()->hasVerifiedEmail();
+    }
+
+    // Validation rules
+    public function rules(): array
+    {
+        return [
+            'title'       => ['required', 'string', 'min:5', 'max:255'],
+            'body'        => ['required', 'string', 'min:50'],
+            'status'      => ['required', Rule::in(['draft', 'published'])],
+            'tags'        => ['nullable', 'array', 'max:5'],
+            'tags.*'      => ['integer', 'exists:tags,id'],
+            'image'       => ['nullable', 'image', 'max:2048', 'mimes:jpg,png,webp'],
+            'published_at' => ['nullable', 'date', 'after:now', Rule::requiredIf($this->status === 'published')],
+        ];
+    }
+
+    // Transform input before validation
+    public function prepareForValidation(): void
+    {
+        $this->merge([
+            'slug'   => Str::slug($this->title ?? ''),
+            'status' => $this->status ?? 'draft',
+        ]);
+    }
+
+    // Custom error messages
+    public function messages(): array
+    {
+        return [
+            'title.required' => 'A post title is required.',
+            'body.min'       => 'Posts must be at least 50 characters.',
+        ];
+    }
+
+    // Custom attribute names in error messages
+    public function attributes(): array
+    {
+        return [
+            'published_at' => 'publication date',
+        ];
+    }
+
+    // After validation hook (complex cross-field validation)
+    public function after(): array
+    {
+        return [
+            function (Validator $validator) {
+                if ($this->hasFile('image') && $this->status === 'draft') {
+                    $validator->errors()->add('image', 'Images cannot be added to draft posts.');
+                }
+            },
+        ];
+    }
+
+    // Safe data for controller use
+    // $request->validated() - only validated fields
+    // $request->safe()->only(['title', 'body']) - subset
+    // $request->safe()->except(['tags']) - exclude
+}
+```
+
+### Testing Form Requests
+
+```php
+it('creates a post with valid data', function () {
+    $user = User::factory()->verified()->create();
+
+    $this->actingAs($user)->postJson('/posts', [
+        'title'  => 'A Valid Post Title',
+        'body'   => str_repeat('a', 50), // meet min:50
+        'status' => 'draft',
+    ])->assertCreated();
+});
+
+it('requires a title', function () {
+    $this->actingAs(User::factory()->verified()->create())
+         ->postJson('/posts', ['body' => str_repeat('a', 50), 'status' => 'draft'])
+         ->assertUnprocessable()
+         ->assertJsonValidationErrors(['title']);
+});
+
+// Test the form request class directly (unit test)
+it('validates correctly', function () {
+    $request = StorePostRequest::create('/posts', 'POST', [
+        'title'  => 'Valid Title',
+        'body'   => str_repeat('a', 50),
+        'status' => 'draft',
+    ]);
+
+    $validator = Validator::make($request->all(), (new StorePostRequest)->rules());
+    expect($validator->fails())->toBeFalse();
+});
+```
+
+---
+
+## Middleware Testing
+
+```php
+// Test route with middleware applied
+it('redirects unauthenticated users', function () {
+    $this->get('/dashboard')->assertRedirect('/login');
+});
+
+// Test with middleware excluded
+it('processes request without auth in test', function () {
+    $response = $this->withoutMiddleware(Authenticate::class)->get('/dashboard');
+    $response->assertOk();
+});
+
+// Exclude all middleware
+$this->withoutMiddleware()->get('/dashboard');
+
+// Exclude CSRF for POST tests (alternative to using withHeaders)
+// Usually unnecessary if using postJson() or RefreshDatabase
+```
+
+---
+
+## Browser Testing with Dusk
+
+### Setup
+
+```bash
+composer require laravel/dusk --dev
+php artisan dusk:install
+# Update APP_URL in .env.dusk.local
+# Start Chrome: php artisan dusk:chrome-driver
+# Run tests: php artisan dusk
+```
+
+### Test Structure
+
+```php
+// tests/Browser/LoginTest.php
+use Laravel\Dusk\Browser;
+use Tests\DuskTestCase;
+
+class LoginTest extends DuskTestCase
+{
+    public function test_user_can_login(): void
+    {
+        $user = User::factory()->create(['password' => Hash::make('password')]);
+
+        $this->browse(function (Browser $browser) use ($user) {
+            $browser->visit('/login')
+                    ->type('email', $user->email)
+                    ->type('password', 'password')
+                    ->press('Login')
+                    ->assertPathIs('/dashboard')
+                    ->assertSee('Welcome back');
+        });
+    }
+
+    public function test_user_can_upload_avatar(): void
+    {
+        $user = User::factory()->create();
+
+        $this->browse(function (Browser $browser) use ($user) {
+            $browser->loginAs($user)
+                    ->visit('/settings/profile')
+                    ->attach('avatar', __DIR__.'/../fixtures/avatar.jpg')
+                    ->press('Save')
+                    ->assertSee('Profile updated');
+        });
+    }
+}
+```
+
+### Dusk Selectors and Assertions
+
+```php
+$browser
+    ->visit('/posts')
+    ->assertTitle('Posts - My App')
+    ->assertSee('Latest Posts')
+    ->assertDontSee('Error')
+    ->click('@create-post-btn')             // dusk="create-post-btn" attribute
+    ->pause(500)                            // ms - prefer waitFor instead
+    ->waitFor('.modal', 5)                  // wait up to 5s
+    ->waitForText('Post created')
+    ->waitUntilMissing('.spinner')
+    ->assertVisible('#post-form')
+    ->assertMissing('.error-message')
+    ->type('input[name=title]', 'My Post')
+    ->select('select[name=status]', 'published')
+    ->check('input[name=featured]')
+    ->uncheck('input[name=notify]')
+    ->radio('input[name=type]', 'article')
+    ->screenshot('after-form-fill')         // saves to tests/Browser/screenshots/
+    ->assertInputValue('title', 'My Post')
+    ->assertChecked('featured')
+    ->press('Submit')
+    ->assertPathIs('/posts')
+    ->assertRouteIs('posts.index');
+
+// JavaScript execution
+$browser->script('document.querySelector(".modal").remove()');
+$value = $browser->value('#hidden-input');
+
+// Multiple browsers (for real-time features)
+$this->browse(function (Browser $alice, Browser $bob) {
+    $alice->loginAs($this->user)->visit('/chat');
+    $bob->loginAs($this->otherUser)->visit('/chat')
+        ->type('#message', 'Hello!')
+        ->press('Send');
+    $alice->waitForText('Hello!')->assertSee('Hello!');
+});
+```
+
+---
+
+## Test Helpers and Utilities
+
+### Custom Test Helpers
+
+```php
+// tests/TestCase.php - add reusable methods
+abstract class TestCase extends BaseTestCase
+{
+    protected function signIn(?User $user = null): User
+    {
+        $user ??= User::factory()->create();
+        $this->actingAs($user);
+        return $user;
+    }
+
+    protected function signInAsAdmin(): User
+    {
+        $admin = User::factory()->admin()->create();
+        $this->actingAs($admin);
+        return $admin;
+    }
+
+    protected function assertValidationError(TestResponse $response, string $field): void
+    {
+        $response->assertUnprocessable()
+                 ->assertJsonValidationErrors([$field]);
+    }
+}
+```
+
+### Parallel Testing
+
+```bash
+# Run tests in parallel (requires brianium/paratest)
+composer require brianium/paratest --dev
+php artisan test --parallel
+php artisan test --parallel --processes=4
+```
+
+```php
+// Use separate test database per process
+// phpunit.xml: <env name="DB_DATABASE" value="app_testing_${TEST_TOKEN}"/>
+// Or configure in ParallelRunner
+```
+
+### Test-Specific Configuration
+
+```php
+// .env.testing overrides
+MAIL_MAILER=array
+QUEUE_CONNECTION=sync
+CACHE_STORE=array
+SESSION_DRIVER=array
+
+// Per-test config override
+Config::set('mail.default', 'array');
+Config::set('queue.default', 'sync');
+
+// Freeze time (Carbon)
+$this->travelTo(now()->setDate(2024, 1, 15));
+$this->travelBack();
+Carbon::setTestNow('2024-01-15 12:00:00');
+Carbon::setTestNow();   // reset
+```

+ 0 - 0
skills/laravel-ops/scripts/.gitkeep


+ 280 - 109
skills/mcp-ops/SKILL.md

@@ -1,144 +1,315 @@
 ---
 name: mcp-ops
-description: "Model Context Protocol (MCP) server patterns for building integrations with Claude Code. Triggers on: mcp server, model context protocol, tool handler, mcp resource, mcp tool."
-compatibility: "Requires Python 3.10+ or Node.js 18+ for MCP server development."
+description: "Model Context Protocol server development, tool design, resource handling, and transport configuration. Use for: mcp, model context protocol, mcp server, mcp tool, mcp resource, fastmcp, mcp transport, stdio, sse, streamable http, mcp inspector, tool handler, mcp prompt."
 allowed-tools: "Read Write Bash"
-depends-on: []
-related-skills: [claude-code-hooks, claude-code-debug]
+related-skills: [claude-code-hooks, claude-code-debug, typescript-ops, python-fastapi-ops]
 ---
 
-# MCP Patterns
+# MCP Operations
 
-Model Context Protocol (MCP) server patterns for building integrations with Claude Code.
+Comprehensive patterns for building, testing, and deploying Model Context Protocol servers in Python and TypeScript.
 
-## Basic MCP Server (Python)
+## MCP Architecture Quick Reference
+
+```
+┌─────────────────────────────────────────────────────────┐
+│                     MCP Host                            │
+│  (Claude Desktop, Claude Code, Custom App)              │
+│                                                         │
+│  ┌───────────┐   ┌───────────┐   ┌───────────┐        │
+│  │  Client A  │   │  Client B  │   │  Client C  │       │
+│  └─────┬─────┘   └─────┬─────┘   └─────┬─────┘        │
+└────────┼───────────────┼───────────────┼────────────────┘
+         │               │               │
+    ┌────┴────┐     ┌────┴────┐     ┌────┴────┐
+    │Transport│     │Transport│     │Transport│
+    │ (stdio) │     │  (SSE)  │     │ (HTTP)  │
+    └────┬────┘     └────┬────┘     └────┬────┘
+         │               │               │
+┌────────┴──┐     ┌──────┴────┐   ┌──────┴────┐
+│  Server A  │     │  Server B  │   │  Server C  │
+│            │     │            │   │            │
+│ ┌────────┐ │     │ ┌────────┐ │   │ ┌────────┐ │
+│ │ Tools  │ │     │ │Resources│ │   │ │Prompts │ │
+│ └────────┘ │     │ └────────┘ │   │ └────────┘ │
+│ ┌────────┐ │     │ ┌────────┐ │   │ ┌────────┐ │
+│ │Resources│ │     │ │Prompts │ │   │ │ Tools  │ │
+│ └────────┘ │     │ └────────┘ │   │ └────────┘ │
+└────────────┘     └────────────┘   └────────────┘
+
+Protocol: JSON-RPC 2.0 over chosen transport
+Flow:     Client → request → Server → response → Client
+```
+
+## Server Type Decision Tree
+
+```
+What transport does your MCP server need?
+│
+├─ Local CLI tool / single-user desktop integration?
+│  └─ stdio
+│     - Simplest setup, no networking
+│     - Claude Desktop, Claude Code native support
+│     - Process lifecycle managed by host
+│
+├─ Web dashboard / browser-based client?
+│  └─ SSE (Server-Sent Events)
+│     - HTTP-based, works through firewalls
+│     - Persistent connection for server→client events
+│     - Good for development and internal tools
+│
+└─ Production API / multi-tenant / cloud deployment?
+   └─ Streamable HTTP
+      - HTTP POST for requests, SSE for streaming responses
+      - Supports stateless and stateful modes
+      - Full auth support, load balancer friendly
+      - Recommended for production deployments
+```
+
+## Tool vs Resource vs Prompt Decision Tree
+
+```
+What does the LLM need to do?
+│
+├─ Perform an action or computation?
+│  └─ TOOL
+│     - Has side effects (API calls, file writes, DB mutations)
+│     - Accepts structured input, returns results
+│     - Examples: run_query, create_issue, send_email
+│
+├─ Read data or context?
+│  └─ RESOURCE
+│     - Read-only data retrieval
+│     - Identified by URI (file://, db://, api://)
+│     - Examples: config://app, schema://users, file://readme.md
+│
+└─ Guide the LLM's behavior or workflow?
+   └─ PROMPT
+      - Templated instructions with arguments
+      - Suggests conversation starters or workflows
+      - Examples: code_review(language, file), summarize(topic)
+```
+
+## Python SDK Quick Start
 
 ```python
-from mcp.server import Server
-from mcp.server.stdio import stdio_server
-
-app = Server("my-server")
-
-@app.list_tools()
-async def list_tools():
-    return [
-        {
-            "name": "my_tool",
-            "description": "Does something useful",
-            "inputSchema": {
-                "type": "object",
-                "properties": {
-                    "query": {"type": "string", "description": "Search query"}
-                },
-                "required": ["query"]
-            }
-        }
-    ]
-
-@app.call_tool()
-async def call_tool(name: str, arguments: dict):
-    if name == "my_tool":
-        result = await do_something(arguments["query"])
-        return {"content": [{"type": "text", "text": result}]}
-    raise ValueError(f"Unknown tool: {name}")
-
-async def main():
-    async with stdio_server() as (read_stream, write_stream):
-        await app.run(read_stream, write_stream, app.create_initialization_options())
+from mcp.server.fastmcp import FastMCP
+
+mcp = FastMCP("my-server")
+
+@mcp.tool()
+def search_docs(query: str) -> str:
+    """Search documentation by keyword."""
+    results = perform_search(query)
+    return "\n".join(f"- {r.title}: {r.snippet}" for r in results)
+
+@mcp.tool()
+def create_ticket(title: str, body: str, priority: str = "medium") -> str:
+    """Create a support ticket."""
+    ticket = api.create(title=title, body=body, priority=priority)
+    return f"Created ticket #{ticket.id}: {ticket.url}"
+
+@mcp.resource("config://app")
+def get_config() -> str:
+    """Return current application configuration."""
+    return json.dumps(load_config(), indent=2)
+
+@mcp.resource("schema://db/{table}")
+def get_table_schema(table: str) -> str:
+    """Return the schema for a database table."""
+    return json.dumps(get_schema(table), indent=2)
+
+@mcp.prompt()
+def code_review(language: str, filepath: str) -> str:
+    """Generate a code review prompt for the given file."""
+    return f"Review this {language} code in {filepath} for bugs, style issues, and performance."
 
 if __name__ == "__main__":
-    import asyncio
-    asyncio.run(main())
+    mcp.run()  # Defaults to stdio transport
 ```
 
-## Project Layout
+**Install and run:**
 
-```
-my-mcp-server/
-├── src/
-│   └── my_server/
-│       ├── __init__.py
-│       ├── server.py       # Main server logic
-│       ├── tools.py        # Tool handlers
-│       └── resources.py    # Resource handlers
-├── pyproject.toml
-└── README.md
+```bash
+uv init my-mcp-server && cd my-mcp-server
+uv add mcp[cli]
+# Run with: uv run python server.py
+# Or:       uv run mcp run server.py
 ```
 
-## Claude Desktop Configuration
+## TypeScript SDK Quick Start
 
-### Basic Configuration
+```typescript
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
+import { z } from "zod";
 
-```json
-{
-  "mcpServers": {
-    "my-server": {
-      "command": "python",
-      "args": ["-m", "my_server"],
-      "env": {
-        "MY_API_KEY": "your-key-here"
-      }
-    }
+const server = new McpServer({
+  name: "my-server",
+  version: "1.0.0",
+});
+
+// Register a tool
+server.tool(
+  "search_docs",
+  "Search documentation by keyword",
+  { query: z.string().describe("Search query") },
+  async ({ query }) => {
+    const results = await performSearch(query);
+    return {
+      content: [{ type: "text", text: results.join("\n") }],
+    };
   }
+);
+
+// Register a resource
+server.resource(
+  "config",
+  "config://app",
+  { description: "Current application configuration" },
+  async (uri) => ({
+    contents: [{
+      uri: uri.href,
+      mimeType: "application/json",
+      text: JSON.stringify(loadConfig(), null, 2),
+    }],
+  })
+);
+
+// Register a prompt
+server.prompt(
+  "code_review",
+  "Generate a code review prompt",
+  { language: z.string(), filepath: z.string() },
+  async ({ language, filepath }) => ({
+    messages: [{
+      role: "user",
+      content: {
+        type: "text",
+        text: `Review this ${language} code in ${filepath} for bugs and style issues.`,
+      },
+    }],
+  })
+);
+
+async function main() {
+  const transport = new StdioServerTransport();
+  await server.connect(transport);
 }
+main().catch(console.error);
 ```
 
-### With uv (Recommended)
+**Install and run:**
+
+```bash
+npm init -y
+npm install @modelcontextprotocol/sdk zod
+npx tsx server.ts
+```
+
+## Transport Selection Matrix
+
+| Feature | stdio | SSE | Streamable HTTP |
+|---------|-------|-----|-----------------|
+| **Use case** | Local CLI tools, desktop | Web dashboards, dev | Production APIs |
+| **Protocol** | stdin/stdout pipes | HTTP + EventSource | HTTP POST + SSE |
+| **Auth support** | Env vars only | Bearer tokens | Full OAuth2/PKCE |
+| **Deployment** | Local process | Single server | Load balanced |
+| **Reconnection** | Process restart | Auto-reconnect | Stateless resilient |
+| **Multi-client** | 1:1 only | Multiple clients | Horizontally scalable |
+| **Firewall** | N/A (local) | HTTP-friendly | HTTP-friendly |
+| **State** | Process lifetime | Connection lifetime | Session or stateless |
+| **Best for** | Claude Desktop/Code | Internal tools | Cloud/enterprise |
+
+## Authentication Patterns Quick Reference
+
+```python
+# Pattern 1: API keys from environment
+import os
+from mcp.server.fastmcp import FastMCP
+
+mcp = FastMCP("api-server")
+
+@mcp.tool()
+def call_api(endpoint: str) -> str:
+    """Call external API with configured credentials."""
+    api_key = os.environ["MY_API_KEY"]  # Set in client config
+    resp = httpx.get(f"https://api.example.com/{endpoint}",
+                     headers={"Authorization": f"Bearer {api_key}"})
+    return resp.text
+```
+
+```python
+# Pattern 2: OAuth2 token refresh (in-memory cache)
+import time
+
+_token_cache: dict = {}
+
+async def get_valid_token() -> str:
+    if _token_cache.get("expires_at", 0) > time.time() + 60:
+        return _token_cache["access_token"]
+    resp = await httpx.AsyncClient().post("https://auth.example.com/token", data={
+        "grant_type": "refresh_token",
+        "refresh_token": os.environ["REFRESH_TOKEN"],
+        "client_id": os.environ["CLIENT_ID"],
+    })
+    data = resp.json()
+    _token_cache.update({
+        "access_token": data["access_token"],
+        "expires_at": time.time() + data["expires_in"],
+    })
+    return data["access_token"]
+```
 
 ```json
+// Claude Desktop config with env vars
 {
   "mcpServers": {
     "my-server": {
       "command": "uv",
-      "args": ["run", "--directory", "/path/to/my-server", "python", "-m", "my_server"],
+      "args": ["run", "--directory", "/path/to/server", "python", "server.py"],
       "env": {
-        "MY_API_KEY": "your-key-here"
+        "MY_API_KEY": "sk-...",
+        "DATABASE_URL": "postgresql://..."
       }
     }
   }
 }
 ```
 
-## Quick Reference
-
-| Pattern | Use Case | Reference |
-|---------|----------|-----------|
-| Tool validation | Input sanitization with Pydantic | `./references/tool-patterns.md` |
-| Error handling | Graceful failure responses | `./references/tool-patterns.md` |
-| Multiple tools | CRUD-style tool registration | `./references/tool-patterns.md` |
-| Static resources | Config/settings exposure | `./references/resource-patterns.md` |
-| Dynamic resources | Database-backed resources | `./references/resource-patterns.md` |
-| Environment auth | API key from env vars | `./references/auth-patterns.md` |
-| OAuth tokens | Token refresh with TTL | `./references/auth-patterns.md` |
-| SQLite cache | Persistent state storage | `./references/state-patterns.md` |
-| In-memory cache | TTL-based caching | `./references/state-patterns.md` |
-| Manual testing | Quick validation script | `./references/testing-ops.md` |
-| pytest async | Unit tests for tools | `./references/testing-ops.md` |
-
-## Common Issues
-
-| Issue | Solution |
-|-------|----------|
-| Server not starting | Check `command` path, ensure dependencies installed |
-| Tool not appearing | Verify `list_tools()` returns valid schema |
-| Auth failures | Check env vars are set in config, not shell |
-| Timeout errors | Add timeout to httpx calls, use async properly |
-| JSON parse errors | Ensure `call_tool` returns proper content structure |
-
-## Official Documentation
-
-- https://modelcontextprotocol.io - MCP specification
-- https://modelcontextprotocol.io/docs/concepts/tools - Tools reference
-- https://modelcontextprotocol.io/docs/concepts/resources - Resources reference
-- https://github.com/modelcontextprotocol/python-sdk - Python SDK
-- https://github.com/modelcontextprotocol/servers - Official MCP servers
-
-## Additional Resources
-
-For detailed patterns, load:
-
-- `./references/tool-patterns.md` - Validation, error handling, multi-tool registration
-- `./references/resource-patterns.md` - Static and dynamic resource exposure
-- `./references/auth-patterns.md` - Environment variables, OAuth token refresh
-- `./references/state-patterns.md` - SQLite persistence, in-memory caching
-- `./references/testing-ops.md` - Manual test scripts, pytest async patterns
+## Common Gotchas
+
+| Gotcha | Why | Fix |
+|--------|-----|-----|
+| Tool not appearing in client | `inputSchema` has invalid JSON Schema | Validate schema with jsonschema library; use Pydantic/Zod to generate |
+| Tool returns raw object | Results must be `content` list with typed items | Always return `{"content": [{"type": "text", "text": "..."}]}` |
+| Timeout on long operations | Default client timeout is often 30-60s | Add progress notifications; break into smaller operations |
+| Concurrent requests fail | Tool handler uses shared mutable state | Use asyncio locks, or make handlers stateless |
+| Large response crashes client | MCP messages have practical size limits | Paginate results; return summaries with detail-fetch tools |
+| Error swallowed silently | Exception in handler returns generic error | Set `isError: true` in response; include error message in content |
+| SSE connection drops | No keep-alive or reconnection logic | Implement heartbeat; client auto-reconnects on SSE |
+| Client ignores new tools | Capabilities not updated after tool change | Call `server.request_context.session.send_resource_list_changed()` |
+| Tool name collision | Two servers register same tool name | Namespace tools: `myserver_search` not just `search` |
+| Resource URI too generic | `data://info` is ambiguous | Use specific schemes: `db://myapp/users`, `config://myapp/settings` |
+| `async def` missing on handler | FastMCP tools can be sync or async, but I/O should be async | Use `async def` for any handler doing network/file I/O |
+| Server works locally, fails in Claude Desktop | Different working directory or PATH | Use absolute paths; log `os.getcwd()` on startup |
+
+## Reference Files
+
+| File | Lines | Content |
+|------|-------|---------|
+| `references/server-architecture.md` | ~700 | Server lifecycle, FastMCP/TS SDK setup, capabilities, middleware, error handling |
+| `references/tool-handlers.md` | ~650 | Schema design, validation, return types, composition, side effects, examples |
+| `references/resources-prompts.md` | ~550 | Resource URIs, static/dynamic resources, templates, prompts, subscriptions |
+| `references/transport-auth.md` | ~550 | stdio/SSE/HTTP transports, session management, OAuth2, rate limiting, TLS |
+| `references/testing-debugging.md` | ~550 | MCP Inspector, unit/integration testing, protocol debugging, CI, performance |
+
+## See Also
+
+- **MCP Specification**: https://spec.modelcontextprotocol.io
+- **Python SDK**: https://github.com/modelcontextprotocol/python-sdk
+- **TypeScript SDK**: https://github.com/modelcontextprotocol/typescript-sdk
+- **Official MCP Servers**: https://github.com/modelcontextprotocol/servers
+- **MCP Inspector**: `npx @modelcontextprotocol/inspector`
+- **FastMCP Documentation**: https://gofastmcp.com
+- **Related skills**: `claude-code-hooks` (hook into Claude Code), `claude-code-debug` (debug Claude Code issues)

+ 0 - 50
skills/mcp-ops/references/auth-patterns.md

@@ -1,50 +0,0 @@
-# MCP Authentication Patterns
-
-Patterns for handling authentication in MCP servers.
-
-## Environment Variables
-
-```python
-import os
-
-API_KEY = os.environ.get("MY_API_KEY")
-if not API_KEY:
-    raise ValueError("MY_API_KEY environment variable required")
-
-async def make_api_call(endpoint: str):
-    async with httpx.AsyncClient() as client:
-        response = await client.get(
-            f"https://api.example.com/{endpoint}",
-            headers={"Authorization": f"Bearer {API_KEY}"}
-        )
-        response.raise_for_status()
-        return response.json()
-```
-
-## OAuth Token Refresh
-
-```python
-from datetime import datetime, timedelta
-
-class TokenManager:
-    def __init__(self):
-        self.token = None
-        self.expires_at = None
-
-    async def get_token(self) -> str:
-        if self.token and self.expires_at > datetime.now():
-            return self.token
-
-        # Refresh token
-        async with httpx.AsyncClient() as client:
-            response = await client.post(
-                "https://auth.example.com/token",
-                data={"grant_type": "client_credentials", ...}
-            )
-            data = response.json()
-            self.token = data["access_token"]
-            self.expires_at = datetime.now() + timedelta(seconds=data["expires_in"] - 60)
-            return self.token
-
-token_manager = TokenManager()
-```

+ 0 - 48
skills/mcp-ops/references/resource-patterns.md

@@ -1,48 +0,0 @@
-# MCP Resource Patterns
-
-Patterns for exposing resources via MCP.
-
-## Static Resource
-
-```python
-@app.list_resources()
-async def list_resources():
-    return [
-        {
-            "uri": "config://settings",
-            "name": "Application Settings",
-            "mimeType": "application/json"
-        }
-    ]
-
-@app.read_resource()
-async def read_resource(uri: str):
-    if uri == "config://settings":
-        return json.dumps({"theme": "dark", "lang": "en"})
-    raise ValueError(f"Unknown resource: {uri}")
-```
-
-## Dynamic Resources
-
-```python
-@app.list_resources()
-async def list_resources():
-    # List available resources dynamically
-    items = await get_all_items()
-    return [
-        {
-            "uri": f"item://{item.id}",
-            "name": item.name,
-            "mimeType": "application/json"
-        }
-        for item in items
-    ]
-
-@app.read_resource()
-async def read_resource(uri: str):
-    if uri.startswith("item://"):
-        item_id = uri.replace("item://", "")
-        item = await get_item(item_id)
-        return json.dumps(item.to_dict())
-    raise ValueError(f"Unknown resource: {uri}")
-```

+ 560 - 0
skills/mcp-ops/references/resources-prompts.md

@@ -0,0 +1,560 @@
+# Resources and Prompts
+
+## Resource Overview
+
+Resources provide **read-only data** to the LLM. They are identified by URIs and can be static or dynamic.
+
+```
+Resource URI format:  scheme://authority/path
+Examples:
+  file:///workspace/readme.md
+  db://myapp/users
+  config://settings
+  api://github/repos/user/repo
+```
+
+## Resource URIs
+
+### URI Scheme Design
+
+| Scheme | Use Case | Example |
+|--------|----------|---------|
+| `file://` | Local filesystem | `file:///workspace/src/main.py` |
+| `db://` | Database objects | `db://myapp/tables/users` |
+| `config://` | Configuration | `config://app/settings` |
+| `api://` | External API data | `api://github/repos` |
+| `schema://` | Schema definitions | `schema://db/users` |
+| `log://` | Log files/entries | `log://app/errors/today` |
+| `docs://` | Documentation | `docs://api/endpoints` |
+
+### URI Templates
+
+URI templates allow parameterized resources:
+
+```python
+from mcp.server.fastmcp import FastMCP
+
+mcp = FastMCP("resource-server")
+
+# Static URI - single resource
+@mcp.resource("config://app/settings")
+def get_settings() -> str:
+    """Return application settings."""
+    return json.dumps(load_settings(), indent=2)
+
+# URI template - parameterized resource
+@mcp.resource("db://app/tables/{table_name}")
+def get_table_data(table_name: str) -> str:
+    """Return data from a database table."""
+    if table_name not in ALLOWED_TABLES:
+        raise ValueError(f"Table {table_name} not accessible")
+    rows = db.query(f"SELECT * FROM {table_name} LIMIT 100")
+    return json.dumps(rows, default=str, indent=2)
+
+# Template with multiple parameters
+@mcp.resource("api://github/{owner}/{repo}/info")
+def get_repo_info(owner: str, repo: str) -> str:
+    """Return GitHub repository information."""
+    resp = httpx.get(f"https://api.github.com/repos/{owner}/{repo}")
+    return resp.text
+```
+
+### TypeScript Resources
+
+```typescript
+// Static resource
+server.resource("settings", "config://app/settings", async (uri) => ({
+  contents: [{
+    uri: uri.href,
+    mimeType: "application/json",
+    text: JSON.stringify(loadSettings(), null, 2),
+  }],
+}));
+
+// Resource template
+server.resource(
+  "table_data",
+  new ResourceTemplate("db://app/tables/{table_name}", { list: undefined }),
+  async (uri, { table_name }) => ({
+    contents: [{
+      uri: uri.href,
+      mimeType: "application/json",
+      text: JSON.stringify(await db.query(`SELECT * FROM ${table_name} LIMIT 100`)),
+    }],
+  })
+);
+```
+
+## Static Resources
+
+Resources backed by files, configs, or constant data:
+
+```python
+import os
+import json
+
+@mcp.resource("config://app/environment")
+def get_environment() -> str:
+    """Return current environment configuration."""
+    return json.dumps({
+        "node_env": os.environ.get("NODE_ENV", "development"),
+        "debug": os.environ.get("DEBUG", "false"),
+        "version": "1.0.0",
+    }, indent=2)
+
+@mcp.resource("docs://api/openapi")
+def get_openapi_spec() -> str:
+    """Return the OpenAPI specification."""
+    with open("openapi.yaml") as f:
+        return f.read()
+
+@mcp.resource("schema://database/migrations")
+def get_migration_status() -> str:
+    """Return database migration status."""
+    migrations = get_applied_migrations()
+    pending = get_pending_migrations()
+    return json.dumps({
+        "applied": [m.name for m in migrations],
+        "pending": [m.name for m in pending],
+        "current_version": migrations[-1].version if migrations else "none",
+    }, indent=2)
+```
+
+## Dynamic Resources
+
+Resources that fetch data on-demand from external sources:
+
+```python
+import httpx
+
+@mcp.resource("api://weather/{city}")
+async def get_weather(city: str) -> str:
+    """Return current weather for a city."""
+    async with httpx.AsyncClient() as client:
+        resp = await client.get(
+            "https://api.weather.example.com/current",
+            params={"city": city},
+            timeout=10.0,
+        )
+        return resp.text
+
+@mcp.resource("db://app/stats")
+def get_app_stats() -> str:
+    """Return application statistics."""
+    stats = {
+        "total_users": db.count("users"),
+        "active_sessions": db.count("sessions", {"active": True}),
+        "requests_today": db.count("requests", {"date": today()}),
+        "error_rate": db.query_scalar(
+            "SELECT COUNT(*) FILTER (WHERE status >= 500)::float / COUNT(*) FROM requests WHERE date = $1",
+            today(),
+        ),
+    }
+    return json.dumps(stats, indent=2)
+
+@mcp.resource("log://app/errors/recent")
+def get_recent_errors() -> str:
+    """Return the most recent application errors."""
+    errors = db.query(
+        "SELECT timestamp, level, message, stack_trace FROM logs "
+        "WHERE level = 'ERROR' ORDER BY timestamp DESC LIMIT 20"
+    )
+    return json.dumps(errors, default=str, indent=2)
+```
+
+## MIME Types
+
+| MIME Type | Use For | Example |
+|-----------|---------|---------|
+| `text/plain` | Plain text, logs | Default if unspecified |
+| `application/json` | Structured data | API responses, configs |
+| `text/markdown` | Formatted docs | README, documentation |
+| `text/html` | Web content | Rendered pages |
+| `image/png` | PNG images | Charts, screenshots (base64) |
+| `image/jpeg` | JPEG images | Photos (base64) |
+| `application/pdf` | PDF documents | Reports (base64) |
+
+### Setting MIME Types
+
+```python
+# Python - FastMCP infers from return type, or specify explicitly
+@mcp.resource("docs://readme", mime_type="text/markdown")
+def get_readme() -> str:
+    """Return the project README."""
+    with open("README.md") as f:
+        return f.read()
+
+# Binary content with base64
+@mcp.resource("images://logo")
+def get_logo() -> bytes:
+    """Return the application logo."""
+    with open("logo.png", "rb") as f:
+        return f.read()  # FastMCP handles base64 encoding
+```
+
+```typescript
+// TypeScript - specify mimeType in contents
+server.resource("readme", "docs://readme", async (uri) => ({
+  contents: [{
+    uri: uri.href,
+    mimeType: "text/markdown",
+    text: await fs.readFile("README.md", "utf-8"),
+  }],
+}));
+
+// Binary content
+server.resource("logo", "images://logo", async (uri) => ({
+  contents: [{
+    uri: uri.href,
+    mimeType: "image/png",
+    blob: (await fs.readFile("logo.png")).toString("base64"),
+  }],
+}));
+```
+
+## Resource Subscriptions
+
+Clients can subscribe to resource changes and receive notifications:
+
+```python
+from mcp.server.fastmcp import FastMCP, Context
+
+mcp = FastMCP("subscription-demo")
+
+# Track subscriptions
+_config_version = 0
+
+@mcp.resource("config://app/settings")
+def get_settings() -> str:
+    return json.dumps(load_settings(), indent=2)
+
+@mcp.tool()
+async def update_setting(key: str, value: str, ctx: Context) -> str:
+    """Update a configuration setting."""
+    global _config_version
+    save_setting(key, value)
+    _config_version += 1
+
+    # Notify subscribed clients that the resource changed
+    await ctx.request_context.session.send_resource_updated("config://app/settings")
+    return f"Updated {key} = {value}"
+```
+
+## Resource Listing
+
+Servers expose available resources via `resources/list`:
+
+```python
+# FastMCP handles listing automatically for registered resources.
+# For dynamic resources, implement custom listing:
+
+@mcp.resource("db://app/tables/{table_name}")
+def get_table(table_name: str) -> str:
+    """Read data from a database table."""
+    return json.dumps(db.query(f"SELECT * FROM {table_name} LIMIT 50"), default=str)
+
+# Override resource listing to show available tables
+# (FastMCP's resource template handles this automatically when
+#  the template is registered with a list callback)
+```
+
+### Pagination for Large Resource Lists
+
+When you have many resources, consider chunking or metadata:
+
+```python
+@mcp.resource("db://app/tables/{table}/page/{page}")
+def get_table_page(table: str, page: str) -> str:
+    """Read a page of data from a database table.
+
+    Args:
+        table: Table name
+        page: Page number (1-based)
+    """
+    page_num = int(page)
+    offset = (page_num - 1) * 50
+    rows = db.query(f"SELECT * FROM {table} LIMIT 50 OFFSET {offset}")
+    total = db.count(table)
+    return json.dumps({
+        "rows": rows,
+        "page": page_num,
+        "total_pages": (total + 49) // 50,
+        "total_rows": total,
+    }, default=str, indent=2)
+```
+
+---
+
+## Prompt Templates
+
+Prompts are pre-written templates that suggest how the LLM should approach a task.
+
+### Basic Prompts
+
+```python
+from mcp.server.fastmcp import FastMCP
+
+mcp = FastMCP("prompt-server")
+
+@mcp.prompt()
+def code_review(language: str, filepath: str) -> str:
+    """Generate a code review prompt for the given file."""
+    return (
+        f"Please review the following {language} code from {filepath}. "
+        f"Focus on:\n"
+        f"1. Bug risks and edge cases\n"
+        f"2. Performance issues\n"
+        f"3. Code style and readability\n"
+        f"4. Security concerns\n"
+        f"5. Suggestions for improvement\n"
+    )
+
+@mcp.prompt()
+def explain_error(error_message: str, context: str = "") -> str:
+    """Generate a prompt to explain an error message."""
+    prompt = f"Explain this error message and suggest how to fix it:\n\n```\n{error_message}\n```"
+    if context:
+        prompt += f"\n\nContext:\n{context}"
+    return prompt
+```
+
+### TypeScript Prompts
+
+```typescript
+server.prompt(
+  "code_review",
+  "Generate a code review prompt",
+  {
+    language: z.string().describe("Programming language"),
+    filepath: z.string().describe("Path to the file to review"),
+  },
+  async ({ language, filepath }) => ({
+    messages: [{
+      role: "user",
+      content: {
+        type: "text",
+        text: `Review this ${language} code from ${filepath}. Check for bugs, performance, style, and security.`,
+      },
+    }],
+  })
+);
+```
+
+### Prompt Arguments
+
+```python
+from typing import Optional
+
+@mcp.prompt()
+def sql_query_help(
+    task: str,
+    database_type: str = "postgresql",
+    tables: Optional[str] = None,
+) -> str:
+    """Help write a SQL query for the given task.
+
+    Args:
+        task: What the query should do
+        database_type: Target database (postgresql, mysql, sqlite)
+        tables: Comma-separated list of relevant tables
+    """
+    prompt = f"Write a {database_type} SQL query to: {task}\n"
+    if tables:
+        prompt += f"\nRelevant tables: {tables}"
+        prompt += "\nPlease query the table schemas first if you need to understand the structure."
+    prompt += "\n\nRequirements:\n"
+    prompt += "- Use parameterized queries (no string interpolation)\n"
+    prompt += "- Include appropriate indexes if suggesting schema changes\n"
+    prompt += "- Add comments explaining complex joins or subqueries\n"
+    return prompt
+```
+
+### Multi-Turn Prompts
+
+Prompts can include multiple messages for conversation setup:
+
+```python
+@mcp.prompt()
+def debug_session(error_type: str, language: str) -> list[dict]:
+    """Start a debugging session for a specific error type."""
+    return [
+        {
+            "role": "system",
+            "content": f"You are a {language} debugging expert. Help the user systematically debug their {error_type} error.",
+        },
+        {
+            "role": "user",
+            "content": (
+                f"I'm encountering a {error_type} in my {language} code. "
+                "Please help me debug it step by step. "
+                "Start by asking me for the error message and relevant code."
+            ),
+        },
+    ]
+```
+
+```typescript
+server.prompt(
+  "debug_session",
+  "Start a debugging session",
+  {
+    error_type: z.string().describe("Type of error (e.g., TypeError, ConnectionError)"),
+    language: z.string().describe("Programming language"),
+  },
+  async ({ error_type, language }) => ({
+    messages: [
+      {
+        role: "assistant",
+        content: {
+          type: "text",
+          text: `I'll help you debug your ${error_type} in ${language}. Let's work through this systematically.\n\nFirst, can you share:\n1. The full error message and stack trace\n2. The relevant code section\n3. What you've already tried`,
+        },
+      },
+    ],
+  })
+);
+```
+
+### Prompts that Reference Resources
+
+Combine prompts with resources for context-aware interactions:
+
+```python
+@mcp.resource("schema://db/{table}")
+def get_table_schema(table: str) -> str:
+    """Return the schema for a database table."""
+    schema = db.get_schema(table)
+    return json.dumps(schema, indent=2)
+
+@mcp.prompt()
+def optimize_query(table: str, slow_query: str) -> list[dict]:
+    """Help optimize a slow SQL query with schema context."""
+    return [
+        {
+            "role": "user",
+            "content": [
+                {
+                    "type": "resource",
+                    "resource": {
+                        "uri": f"schema://db/{table}",
+                        "text": get_table_schema(table),
+                        "mimeType": "application/json",
+                    },
+                },
+                {
+                    "type": "text",
+                    "text": (
+                        f"This query against the `{table}` table is slow:\n\n"
+                        f"```sql\n{slow_query}\n```\n\n"
+                        "Please suggest optimizations, including index recommendations."
+                    ),
+                },
+            ],
+        },
+    ]
+```
+
+### Guided Workflows
+
+Use prompts to define multi-step workflows:
+
+```python
+@mcp.prompt()
+def migration_workflow(source_db: str, target_db: str) -> list[dict]:
+    """Guide through a database migration workflow."""
+    return [
+        {
+            "role": "user",
+            "content": (
+                f"I need to migrate data from {source_db} to {target_db}. "
+                "Please guide me through these steps:\n\n"
+                "1. Analyze the source schema\n"
+                "2. Create the target schema (with any needed transformations)\n"
+                "3. Write the migration script\n"
+                "4. Create validation queries to verify the migration\n"
+                "5. Suggest a rollback plan\n\n"
+                "Let's start with step 1."
+            ),
+        },
+    ]
+
+@mcp.prompt()
+def api_design(service_name: str, endpoints: str = "") -> str:
+    """Help design a REST API."""
+    prompt = f"Help me design a REST API for the {service_name} service.\n"
+    if endpoints:
+        prompt += f"\nPlanned endpoints:\n{endpoints}\n"
+    prompt += (
+        "\nFor each endpoint, specify:\n"
+        "- HTTP method and path\n"
+        "- Request/response schemas\n"
+        "- Authentication requirements\n"
+        "- Rate limiting considerations\n"
+        "- Error responses\n"
+    )
+    return prompt
+```
+
+## Prompt Best Practices
+
+| Practice | Why |
+|----------|-----|
+| Use descriptive argument names | LLM and client UIs show argument names |
+| Provide defaults for optional args | Reduces friction for common cases |
+| Include structured instructions | Numbered lists guide the LLM's approach |
+| Reference resources when relevant | Gives the LLM concrete data to work with |
+| Keep prompts focused | One task per prompt, not multi-purpose |
+| Test with real LLM conversations | Prompts that read well may not work well |
+
+## Combining Resources and Tools
+
+A common pattern: resources provide context, tools perform actions:
+
+```python
+# Resource: provides data for the LLM to understand
+@mcp.resource("db://app/tables/{table}/schema")
+def get_schema(table: str) -> str:
+    """Return the schema for a database table."""
+    return json.dumps(db.get_schema(table), indent=2)
+
+@mcp.resource("db://app/tables/{table}/stats")
+def get_stats(table: str) -> str:
+    """Return statistics for a database table."""
+    return json.dumps({
+        "row_count": db.count(table),
+        "size_bytes": db.table_size(table),
+        "last_modified": db.last_modified(table).isoformat(),
+    }, indent=2)
+
+# Tool: performs actions using the context from resources
+@mcp.tool()
+def optimize_table(table: str, strategy: str = "auto") -> str:
+    """Optimize a database table.
+
+    Args:
+        table: Table name to optimize
+        strategy: Optimization strategy: auto, vacuum, reindex, analyze
+    """
+    if strategy == "auto":
+        stats = json.loads(get_stats(table))
+        if stats["row_count"] > 1_000_000:
+            strategy = "vacuum"
+        else:
+            strategy = "analyze"
+
+    result = db.optimize(table, strategy)
+    return f"Optimized {table} using {strategy}: {result}"
+
+# Prompt: guides the LLM to use resources and tools together
+@mcp.prompt()
+def db_health_check() -> str:
+    """Run a database health check."""
+    return (
+        "Please check the health of the database:\n"
+        "1. Read the schema for each table\n"
+        "2. Check the stats for each table\n"
+        "3. Identify any tables that need optimization\n"
+        "4. Run optimize_table on any that need it\n"
+        "5. Summarize the results\n"
+    )
+```

+ 703 - 0
skills/mcp-ops/references/server-architecture.md

@@ -0,0 +1,703 @@
+# MCP Server Architecture
+
+## Protocol Overview
+
+MCP uses **JSON-RPC 2.0** as its wire protocol. Every message is a JSON object with:
+
+- **Requests**: `{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {...}}`
+- **Responses**: `{"jsonrpc": "2.0", "id": 1, "result": {...}}`
+- **Notifications**: `{"jsonrpc": "2.0", "method": "notifications/progress", "params": {...}}` (no `id`, no response expected)
+
+The protocol is transport-agnostic. The same JSON-RPC messages flow over stdio pipes, SSE streams, or HTTP requests.
+
+## Server Lifecycle
+
+```
+┌──────────────┐     ┌──────────────────────┐     ┌─────────┐
+│ Uninitialized │────→│    Initializing       │────→│  Ready  │
+└──────────────┘     │                      │     └────┬────┘
+                     │ Client sends          │          │
+                     │ initialize request    │     ┌────┴────┐
+                     │ Server responds with  │     │ Serving │
+                     │ capabilities          │     │ requests│
+                     │ Client sends          │     └────┬────┘
+                     │ initialized notif     │          │
+                     └──────────────────────┘     ┌────┴────┐
+                                                  │Shutdown │
+                                                  └─────────┘
+```
+
+### Phase 1: Initialization
+
+Client sends `initialize` with its capabilities and protocol version. Server responds with its own capabilities.
+
+```json
+// Client → Server
+{
+  "jsonrpc": "2.0",
+  "id": 1,
+  "method": "initialize",
+  "params": {
+    "protocolVersion": "2025-03-26",
+    "capabilities": {},
+    "clientInfo": { "name": "claude-code", "version": "1.0.0" }
+  }
+}
+
+// Server → Client
+{
+  "jsonrpc": "2.0",
+  "id": 1,
+  "result": {
+    "protocolVersion": "2025-03-26",
+    "capabilities": {
+      "tools": { "listChanged": true },
+      "resources": { "subscribe": true, "listChanged": true },
+      "prompts": { "listChanged": true },
+      "logging": {}
+    },
+    "serverInfo": { "name": "my-server", "version": "1.0.0" }
+  }
+}
+```
+
+### Phase 2: Initialized Notification
+
+Client sends `notifications/initialized` to confirm. Server transitions to ready state.
+
+### Phase 3: Serving
+
+Server handles requests: `tools/list`, `tools/call`, `resources/list`, `resources/read`, `prompts/list`, `prompts/get`, `completion/complete`.
+
+### Phase 4: Shutdown
+
+Transport closes (process exit for stdio, connection close for HTTP/SSE). Server cleans up resources.
+
+## Capability Negotiation
+
+Servers declare what they support during initialization:
+
+| Capability | Meaning | Sub-capabilities |
+|-----------|---------|------------------|
+| `tools` | Server offers tools | `listChanged` - notify on tool list changes |
+| `resources` | Server offers resources | `subscribe` - clients can subscribe to changes; `listChanged` |
+| `prompts` | Server offers prompts | `listChanged` - notify on prompt list changes |
+| `logging` | Server can emit log messages | (none) |
+| `experimental` | Experimental features | Varies by implementation |
+
+## FastMCP Server Setup (Python)
+
+FastMCP is the recommended high-level API for Python MCP servers.
+
+### Basic Server
+
+```python
+from mcp.server.fastmcp import FastMCP
+
+# Create server with metadata
+mcp = FastMCP(
+    "my-server",
+    version="1.0.0",
+    description="A server that does useful things",
+)
+
+@mcp.tool()
+def greet(name: str) -> str:
+    """Greet a user by name."""
+    return f"Hello, {name}!"
+
+if __name__ == "__main__":
+    mcp.run()  # stdio by default
+```
+
+### Server with Dependencies
+
+```python
+from mcp.server.fastmcp import FastMCP
+import httpx
+
+mcp = FastMCP("api-server")
+
+# Dependencies are injected per-request via FastMCP's Context
+@mcp.tool()
+async def fetch_data(url: str) -> str:
+    """Fetch data from a URL."""
+    async with httpx.AsyncClient() as client:
+        resp = await client.get(url, timeout=30.0)
+        resp.raise_for_status()
+        return resp.text
+```
+
+### Lifespan Handlers
+
+Use lifespan to manage resources that live for the server's entire lifetime:
+
+```python
+from contextlib import asynccontextmanager
+from mcp.server.fastmcp import FastMCP
+
+@asynccontextmanager
+async def lifespan(server: FastMCP):
+    """Initialize and cleanup server resources."""
+    # Startup: create connection pools, load config
+    db = await create_db_pool()
+    server.state["db"] = db
+    try:
+        yield
+    finally:
+        # Shutdown: cleanup
+        await db.close()
+
+mcp = FastMCP("db-server", lifespan=lifespan)
+
+@mcp.tool()
+async def query_db(sql: str, ctx: Context) -> str:
+    """Run a read-only SQL query."""
+    db = ctx.server.state["db"]
+    results = await db.fetch(sql)
+    return json.dumps(results, default=str)
+```
+
+### FastMCP Context Object
+
+The `Context` parameter gives tools access to server internals:
+
+```python
+from mcp.server.fastmcp import FastMCP, Context
+
+mcp = FastMCP("context-demo")
+
+@mcp.tool()
+async def long_operation(items: list[str], ctx: Context) -> str:
+    """Process items with progress reporting."""
+    results = []
+    for i, item in enumerate(items):
+        await ctx.report_progress(i, len(items))
+        result = await process_item(item)
+        results.append(result)
+        await ctx.info(f"Processed {item}")  # Log to client
+    return json.dumps(results)
+```
+
+Context provides:
+- `ctx.report_progress(current, total)` - send progress notifications
+- `ctx.info(message)`, `ctx.debug(message)`, `ctx.warning(message)`, `ctx.error(message)` - logging
+- `ctx.read_resource(uri)` - read another resource from within a tool
+- `ctx.server` - access the FastMCP server instance and its state
+- `ctx.request_context` - access the low-level request context and session
+
+### Running with Different Transports
+
+```python
+# stdio (default) - for Claude Desktop / Claude Code
+mcp.run()
+mcp.run(transport="stdio")
+
+# SSE - for web clients
+mcp.run(transport="sse", host="0.0.0.0", port=8000)
+
+# Streamable HTTP - for production
+mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)
+```
+
+## TypeScript SDK Server Setup
+
+### Basic Server with McpServer
+
+```typescript
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
+import { z } from "zod";
+
+const server = new McpServer({
+  name: "my-server",
+  version: "1.0.0",
+});
+
+// Tools, resources, and prompts registered via server methods
+server.tool("greet", "Greet a user", { name: z.string() }, async ({ name }) => ({
+  content: [{ type: "text", text: `Hello, ${name}!` }],
+}));
+
+const transport = new StdioServerTransport();
+await server.connect(transport);
+```
+
+### Low-Level Server API
+
+For maximum control, use the `Server` class directly:
+
+```typescript
+import { Server } from "@modelcontextprotocol/sdk/server/index.js";
+import {
+  CallToolRequestSchema,
+  ListToolsRequestSchema,
+} from "@modelcontextprotocol/sdk/types.js";
+
+const server = new Server(
+  { name: "low-level-server", version: "1.0.0" },
+  { capabilities: { tools: { listChanged: true } } }
+);
+
+server.setRequestHandler(ListToolsRequestSchema, async () => ({
+  tools: [
+    {
+      name: "greet",
+      description: "Greet a user",
+      inputSchema: {
+        type: "object" as const,
+        properties: {
+          name: { type: "string", description: "User name" },
+        },
+        required: ["name"],
+      },
+    },
+  ],
+}));
+
+server.setRequestHandler(CallToolRequestSchema, async (request) => {
+  if (request.params.name === "greet") {
+    const { name } = request.params.arguments as { name: string };
+    return {
+      content: [{ type: "text", text: `Hello, ${name}!` }],
+    };
+  }
+  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
+});
+```
+
+### McpError for Typed Errors
+
+```typescript
+import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
+
+// Standard JSON-RPC error codes
+throw new McpError(ErrorCode.InvalidParams, "Missing required field: query");
+throw new McpError(ErrorCode.MethodNotFound, "Unknown tool: foo");
+throw new McpError(ErrorCode.InternalError, "Database connection failed");
+
+// Custom error codes (use negative numbers per JSON-RPC spec)
+throw new McpError(-32001, "Rate limit exceeded");
+```
+
+## Multi-Tool Server Organization
+
+### By Domain (Recommended)
+
+```python
+# tools/files.py
+from mcp.server.fastmcp import FastMCP
+
+def register_file_tools(mcp: FastMCP):
+    @mcp.tool()
+    def read_file(path: str) -> str:
+        """Read contents of a file."""
+        with open(path) as f:
+            return f.read()
+
+    @mcp.tool()
+    def write_file(path: str, content: str) -> str:
+        """Write content to a file."""
+        with open(path, "w") as f:
+            f.write(content)
+        return f"Wrote {len(content)} bytes to {path}"
+
+    @mcp.tool()
+    def list_files(directory: str) -> str:
+        """List files in a directory."""
+        import os
+        entries = os.listdir(directory)
+        return "\n".join(entries)
+
+# tools/database.py
+def register_db_tools(mcp: FastMCP):
+    @mcp.tool()
+    def query(sql: str) -> str:
+        """Execute a read-only SQL query."""
+        ...
+
+    @mcp.tool()
+    def list_tables() -> str:
+        """List all database tables."""
+        ...
+
+# server.py
+from mcp.server.fastmcp import FastMCP
+from tools.files import register_file_tools
+from tools.database import register_db_tools
+
+mcp = FastMCP("multi-tool-server")
+register_file_tools(mcp)
+register_db_tools(mcp)
+
+if __name__ == "__main__":
+    mcp.run()
+```
+
+### Tool Namespacing
+
+Prefix tool names to avoid collisions when multiple servers are active:
+
+```python
+@mcp.tool(name="myapp_search")  # Not just "search"
+def search(query: str) -> str:
+    ...
+
+@mcp.tool(name="myapp_create_item")  # Not just "create"
+def create_item(title: str) -> str:
+    ...
+```
+
+## Middleware Patterns
+
+### Request Logging
+
+```python
+from mcp.server.fastmcp import FastMCP
+import logging
+
+logger = logging.getLogger("mcp-server")
+mcp = FastMCP("logged-server")
+
+# Use lifespan for server-level middleware
+@asynccontextmanager
+async def lifespan(server: FastMCP):
+    logger.info("Server starting up")
+    yield
+    logger.info("Server shutting down")
+
+# For per-tool logging, use a decorator
+def logged_tool(func):
+    async def wrapper(*args, **kwargs):
+        logger.info(f"Tool called: {func.__name__} with {kwargs}")
+        try:
+            result = await func(*args, **kwargs) if asyncio.iscoroutinefunction(func) else func(*args, **kwargs)
+            logger.info(f"Tool {func.__name__} succeeded")
+            return result
+        except Exception as e:
+            logger.error(f"Tool {func.__name__} failed: {e}")
+            raise
+    wrapper.__name__ = func.__name__
+    wrapper.__doc__ = func.__doc__
+    wrapper.__annotations__ = func.__annotations__
+    return wrapper
+```
+
+### Rate Limiting
+
+```python
+import time
+from collections import defaultdict
+
+class RateLimiter:
+    def __init__(self, max_calls: int, window_seconds: int):
+        self.max_calls = max_calls
+        self.window = window_seconds
+        self.calls: dict[str, list[float]] = defaultdict(list)
+
+    def check(self, key: str) -> bool:
+        now = time.time()
+        self.calls[key] = [t for t in self.calls[key] if now - t < self.window]
+        if len(self.calls[key]) >= self.max_calls:
+            return False
+        self.calls[key].append(now)
+        return True
+
+rate_limiter = RateLimiter(max_calls=60, window_seconds=60)
+
+@mcp.tool()
+async def rate_limited_api_call(endpoint: str, ctx: Context) -> str:
+    """Call an API with rate limiting."""
+    if not rate_limiter.check("api"):
+        return "Rate limit exceeded. Please wait before making more requests."
+    async with httpx.AsyncClient() as client:
+        resp = await client.get(f"https://api.example.com/{endpoint}")
+        return resp.text
+```
+
+### Caching Middleware
+
+```python
+import time
+from functools import wraps
+
+def cached(ttl_seconds: int = 300):
+    """Cache tool results for the given TTL."""
+    cache: dict[str, tuple[float, str]] = {}
+
+    def decorator(func):
+        @wraps(func)
+        async def wrapper(*args, **kwargs):
+            key = f"{func.__name__}:{args}:{kwargs}"
+            if key in cache:
+                cached_at, result = cache[key]
+                if time.time() - cached_at < ttl_seconds:
+                    return result
+            result = await func(*args, **kwargs) if asyncio.iscoroutinefunction(func) else func(*args, **kwargs)
+            cache[key] = (time.time(), result)
+            return result
+        return wrapper
+    return decorator
+
+@mcp.tool()
+@cached(ttl_seconds=60)
+async def get_weather(city: str) -> str:
+    """Get current weather for a city (cached for 60s)."""
+    async with httpx.AsyncClient() as client:
+        resp = await client.get(f"https://weather.api/current?city={city}")
+        return resp.text
+```
+
+## Error Handling
+
+### Python Error Handling
+
+```python
+from mcp.server.fastmcp import FastMCP
+
+mcp = FastMCP("robust-server")
+
+@mcp.tool()
+async def safe_operation(input: str) -> str:
+    """Perform an operation with proper error handling."""
+    try:
+        result = await do_something(input)
+        return json.dumps({"status": "success", "data": result})
+    except ValueError as e:
+        # User-facing error: return as tool result with error flag
+        # FastMCP handles this by returning content with isError=True
+        raise ValueError(f"Invalid input: {e}")
+    except httpx.HTTPError as e:
+        raise RuntimeError(f"API request failed: {e}")
+    except Exception as e:
+        # Log internally, return safe message
+        logger.exception("Unexpected error in safe_operation")
+        raise RuntimeError("An unexpected error occurred. Check server logs.")
+```
+
+### TypeScript Error Handling
+
+```typescript
+server.tool("safe_operation", "Do something safely", { input: z.string() }, async ({ input }) => {
+  try {
+    const result = await doSomething(input);
+    return { content: [{ type: "text", text: JSON.stringify(result) }] };
+  } catch (error) {
+    // Return error as tool result (visible to LLM)
+    return {
+      content: [{ type: "text", text: `Error: ${error.message}` }],
+      isError: true,
+    };
+  }
+});
+```
+
+### Error Categories
+
+| Error Type | Handling | Example |
+|-----------|----------|---------|
+| Invalid input | Return clear message, `isError: true` | "Missing required field: query" |
+| Auth failure | Return message suggesting config check | "API key invalid. Check MY_API_KEY env var" |
+| External API error | Return status + retry suggestion | "GitHub API returned 503. Try again in 30s" |
+| Internal error | Log details, return safe message | "Internal error. Check server logs" |
+| Timeout | Return partial results if available | "Operation timed out. Partial results: ..." |
+
+## Logging
+
+### Python Logging
+
+```python
+from mcp.server.fastmcp import FastMCP, Context
+
+mcp = FastMCP("logged-server")
+
+@mcp.tool()
+async def debug_tool(data: str, ctx: Context) -> str:
+    """A tool with comprehensive logging."""
+    await ctx.debug(f"Received data: {data[:100]}")
+    await ctx.info("Processing started")
+
+    try:
+        result = process(data)
+        await ctx.info(f"Processing complete: {len(result)} items")
+        return json.dumps(result)
+    except Exception as e:
+        await ctx.error(f"Processing failed: {e}")
+        raise
+```
+
+### Log Levels
+
+| Level | Use For |
+|-------|---------|
+| `debug` | Detailed diagnostic info, request/response bodies |
+| `info` | Normal operations, progress updates |
+| `warning` | Recoverable issues, deprecation notices |
+| `error` | Failures that affect the current operation |
+
+## Graceful Shutdown
+
+```python
+import signal
+from contextlib import asynccontextmanager
+from mcp.server.fastmcp import FastMCP
+
+@asynccontextmanager
+async def lifespan(server: FastMCP):
+    # Startup
+    db_pool = await create_pool()
+    http_client = httpx.AsyncClient()
+    server.state["db"] = db_pool
+    server.state["http"] = http_client
+
+    try:
+        yield
+    finally:
+        # Cleanup: close connections, flush buffers
+        await http_client.aclose()
+        await db_pool.close()
+        logger.info("Server shutdown complete")
+
+mcp = FastMCP("graceful-server", lifespan=lifespan)
+```
+
+## Connection and Session Management
+
+### Session Isolation
+
+Each client connection gets its own session. Do not share mutable state between sessions without synchronization:
+
+```python
+# BAD: Shared mutable state without locks
+results_cache = {}  # All sessions share this dict unsafely
+
+# GOOD: Per-session state via context
+@mcp.tool()
+async def track_calls(ctx: Context) -> str:
+    """Track how many times this session called this tool."""
+    session = ctx.request_context.session
+    if not hasattr(session, "call_count"):
+        session.call_count = 0
+    session.call_count += 1
+    return f"This session has made {session.call_count} calls"
+
+# GOOD: Shared state with proper locking
+import asyncio
+_lock = asyncio.Lock()
+_shared_cache: dict = {}
+
+@mcp.tool()
+async def cached_lookup(key: str) -> str:
+    async with _lock:
+        if key not in _shared_cache:
+            _shared_cache[key] = await expensive_lookup(key)
+        return _shared_cache[key]
+```
+
+### Notifying Clients of Changes
+
+When your server's available tools, resources, or prompts change at runtime:
+
+```python
+@mcp.tool()
+async def enable_advanced_tools(ctx: Context) -> str:
+    """Dynamically register new tools and notify the client."""
+    register_advanced_tools(ctx.server)
+    # Notify client that tool list has changed
+    await ctx.request_context.session.send_resource_list_changed()
+    return "Advanced tools enabled"
+```
+
+## Project Structure
+
+### Python (Recommended Layout)
+
+```
+my-mcp-server/
+├── pyproject.toml
+├── src/
+│   └── my_server/
+│       ├── __init__.py
+│       ├── server.py          # FastMCP instance + main()
+│       ├── tools/
+│       │   ├── __init__.py
+│       │   ├── files.py       # File operation tools
+│       │   └── api.py         # API wrapper tools
+│       ├── resources/
+│       │   ├── __init__.py
+│       │   └── config.py      # Configuration resources
+│       └── prompts/
+│           ├── __init__.py
+│           └── workflows.py   # Prompt templates
+└── tests/
+    ├── test_tools.py
+    └── test_resources.py
+```
+
+### TypeScript (Recommended Layout)
+
+```
+my-mcp-server/
+├── package.json
+├── tsconfig.json
+├── src/
+│   ├── index.ts               # Server setup + transport
+│   ├── tools/
+│   │   ├── files.ts
+│   │   └── api.ts
+│   ├── resources/
+│   │   └── config.ts
+│   └── prompts/
+│       └── workflows.ts
+└── tests/
+    ├── tools.test.ts
+    └── resources.test.ts
+```
+
+## Claude Desktop Configuration
+
+```json
+{
+  "mcpServers": {
+    "my-python-server": {
+      "command": "uv",
+      "args": ["run", "--directory", "/path/to/my-server", "python", "-m", "my_server"],
+      "env": {
+        "API_KEY": "your-key",
+        "DATABASE_URL": "postgresql://localhost/mydb"
+      }
+    },
+    "my-ts-server": {
+      "command": "npx",
+      "args": ["tsx", "/path/to/my-server/src/index.ts"],
+      "env": {
+        "API_KEY": "your-key"
+      }
+    }
+  }
+}
+```
+
+## Claude Code Configuration
+
+In `.claude/settings.json` or project settings:
+
+```json
+{
+  "mcpServers": {
+    "my-server": {
+      "command": "uv",
+      "args": ["run", "--directory", "/path/to/server", "python", "server.py"],
+      "env": {
+        "API_KEY": "your-key"
+      }
+    }
+  }
+}
+```
+
+Or via CLI:
+
+```bash
+claude mcp add my-server -- uv run --directory /path/to/server python server.py
+```

+ 0 - 57
skills/mcp-ops/references/state-patterns.md

@@ -1,57 +0,0 @@
-# MCP State Management Patterns
-
-Patterns for persisting and caching state in MCP servers.
-
-## SQLite for Persistence
-
-```python
-import aiosqlite
-
-DB_PATH = Path.home() / ".my-mcp-server" / "state.db"
-
-async def init_db():
-    DB_PATH.parent.mkdir(parents=True, exist_ok=True)
-    async with aiosqlite.connect(DB_PATH) as db:
-        await db.execute("""
-            CREATE TABLE IF NOT EXISTS cache (
-                key TEXT PRIMARY KEY,
-                value TEXT,
-                expires_at TEXT
-            )
-        """)
-        await db.commit()
-
-async def get_cached(key: str) -> str | None:
-    async with aiosqlite.connect(DB_PATH) as db:
-        cursor = await db.execute(
-            "SELECT value FROM cache WHERE key = ? AND expires_at > datetime('now')",
-            (key,)
-        )
-        row = await cursor.fetchone()
-        return row[0] if row else None
-
-async def set_cached(key: str, value: str, ttl_seconds: int = 3600):
-    async with aiosqlite.connect(DB_PATH) as db:
-        await db.execute(
-            "INSERT OR REPLACE INTO cache (key, value, expires_at) VALUES (?, ?, datetime('now', '+' || ? || ' seconds'))",
-            (key, value, ttl_seconds)
-        )
-        await db.commit()
-```
-
-## In-Memory Cache
-
-```python
-from functools import lru_cache
-from cachetools import TTLCache
-
-# Simple TTL cache
-cache = TTLCache(maxsize=100, ttl=300)  # 5 minute TTL
-
-async def get_data(key: str):
-    if key in cache:
-        return cache[key]
-    data = await fetch_from_api(key)
-    cache[key] = data
-    return data
-```

+ 868 - 0
skills/mcp-ops/references/testing-debugging.md

@@ -0,0 +1,868 @@
+# Testing and Debugging
+
+## MCP Inspector
+
+The MCP Inspector is an interactive debugging tool for testing MCP servers without a full client setup.
+
+### Installation and Usage
+
+```bash
+# Run directly with npx (no install needed)
+npx @modelcontextprotocol/inspector
+
+# Connect to a stdio server
+npx @modelcontextprotocol/inspector -- uv run python server.py
+
+# Connect to a stdio server with env vars
+npx @modelcontextprotocol/inspector -e API_KEY=sk-key -- uv run python server.py
+
+# Connect to an SSE server
+npx @modelcontextprotocol/inspector --url http://localhost:8000/sse
+
+# Connect to a Streamable HTTP server
+npx @modelcontextprotocol/inspector --url http://localhost:8000/mcp
+```
+
+### What You Can Do with Inspector
+
+| Feature | How |
+|---------|-----|
+| List tools | Click "Tools" tab to see all registered tools |
+| Call tools | Fill in arguments and click "Call" |
+| List resources | Click "Resources" tab |
+| Read resources | Click any resource to see its contents |
+| List prompts | Click "Prompts" tab |
+| Get prompts | Fill in arguments and see rendered prompt |
+| View messages | "Messages" tab shows raw JSON-RPC traffic |
+| Test notifications | See server notifications in real-time |
+
+### Inspecting Protocol Messages
+
+The Inspector shows raw JSON-RPC messages. Use this to verify:
+
+- Request format matches the MCP specification
+- Response content is properly structured
+- Error codes and messages are correct
+- Notifications are sent at the right times
+
+```
+Example Inspector message log:
+
+→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}
+← {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"tools":{}},...}}
+→ {"jsonrpc":"2.0","method":"notifications/initialized"}
+→ {"jsonrpc":"2.0","id":2,"method":"tools/list"}
+← {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"search",...}]}}
+→ {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search","arguments":{"query":"test"}}}
+← {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"..."}]}}
+```
+
+## Unit Testing Tools (Python)
+
+### Testing with pytest
+
+```python
+# test_tools.py
+import pytest
+import json
+from unittest.mock import AsyncMock, patch, MagicMock
+
+# Test tool functions directly (they're just functions)
+from my_server.server import search_docs, create_ticket
+
+class TestSearchDocs:
+    def test_basic_search(self):
+        """Test search returns formatted results."""
+        with patch("my_server.server.perform_search") as mock_search:
+            mock_search.return_value = [
+                MagicMock(title="Doc 1", snippet="First result"),
+                MagicMock(title="Doc 2", snippet="Second result"),
+            ]
+            result = search_docs("test query")
+            assert "Doc 1" in result
+            assert "Doc 2" in result
+            mock_search.assert_called_once_with("test query")
+
+    def test_empty_search(self):
+        """Test search with no results."""
+        with patch("my_server.server.perform_search") as mock_search:
+            mock_search.return_value = []
+            result = search_docs("nonexistent")
+            assert result == ""
+
+    def test_search_error(self):
+        """Test search handles errors gracefully."""
+        with patch("my_server.server.perform_search") as mock_search:
+            mock_search.side_effect = ConnectionError("API down")
+            with pytest.raises(ConnectionError):
+                search_docs("test")
+
+
+class TestCreateTicket:
+    def test_create_ticket(self):
+        """Test ticket creation returns confirmation."""
+        with patch("my_server.server.api") as mock_api:
+            mock_api.create.return_value = MagicMock(id=42, url="https://example.com/42")
+            result = create_ticket("Bug report", "Something broke", "high")
+            assert "#42" in result
+            assert "https://example.com/42" in result
+
+    def test_create_ticket_validation(self):
+        """Test ticket creation validates required fields."""
+        # FastMCP validates types before calling the function
+        # Test the function's own validation
+        with pytest.raises(ValueError):
+            create_ticket("", "body", "medium")  # Empty title
+```
+
+### Testing Async Tools
+
+```python
+import pytest
+import asyncio
+
+@pytest.mark.asyncio
+async def test_async_tool():
+    """Test an async tool handler."""
+    with patch("my_server.server.httpx.AsyncClient") as mock_client:
+        mock_response = AsyncMock()
+        mock_response.text = '{"data": "test"}'
+        mock_response.raise_for_status = MagicMock()
+        mock_client.return_value.__aenter__ = AsyncMock(return_value=mock_response)
+        mock_client.return_value.__aexit__ = AsyncMock(return_value=False)
+
+        # Better: use a real mock client
+        mock_instance = AsyncMock()
+        mock_instance.get.return_value = mock_response
+
+        with patch("my_server.server.httpx.AsyncClient") as MockClient:
+            MockClient.return_value.__aenter__.return_value = mock_instance
+            MockClient.return_value.__aexit__.return_value = False
+
+            result = await fetch_data("https://example.com/api")
+            assert "test" in result
+```
+
+### Testing with FastMCP Test Client
+
+```python
+import pytest
+from mcp.server.fastmcp import FastMCP
+
+# Create a test server
+mcp = FastMCP("test-server")
+
+@mcp.tool()
+def add(a: int, b: int) -> str:
+    """Add two numbers."""
+    return str(a + b)
+
+@mcp.resource("config://test")
+def get_config() -> str:
+    return '{"key": "value"}'
+
+@pytest.mark.asyncio
+async def test_tool_via_mcp():
+    """Test tools through the MCP protocol layer."""
+    async with mcp.test_client() as client:
+        # List tools
+        tools = await client.list_tools()
+        assert any(t.name == "add" for t in tools)
+
+        # Call a tool
+        result = await client.call_tool("add", {"a": 2, "b": 3})
+        assert result[0].text == "5"
+
+@pytest.mark.asyncio
+async def test_resource_via_mcp():
+    """Test resources through the MCP protocol layer."""
+    async with mcp.test_client() as client:
+        resources = await client.list_resources()
+        assert any(r.uri == "config://test" for r in resources)
+
+        content = await client.read_resource("config://test")
+        assert '"key"' in content[0].text
+```
+
+## Unit Testing Tools (TypeScript)
+
+### Testing with vitest
+
+```typescript
+// tools.test.ts
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { z } from "zod";
+
+describe("search tool", () => {
+  let server: McpServer;
+  let client: Client;
+
+  beforeEach(async () => {
+    server = new McpServer({ name: "test", version: "1.0.0" });
+
+    server.tool("search", "Search docs", { query: z.string() }, async ({ query }) => {
+      // In real code, this calls an external service
+      const results = await mockSearch(query);
+      return {
+        content: [{ type: "text", text: results.join("\n") }],
+      };
+    });
+
+    // Connect via in-memory transport
+    const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
+    await server.connect(serverTransport);
+
+    client = new Client({ name: "test-client", version: "1.0.0" });
+    await client.connect(clientTransport);
+  });
+
+  it("should return search results", async () => {
+    const result = await client.callTool({ name: "search", arguments: { query: "test" } });
+    expect(result.content).toHaveLength(1);
+    expect(result.content[0].type).toBe("text");
+  });
+
+  it("should list tools", async () => {
+    const tools = await client.listTools();
+    expect(tools.tools).toHaveLength(1);
+    expect(tools.tools[0].name).toBe("search");
+  });
+});
+```
+
+### Testing Error Handling
+
+```typescript
+describe("error handling", () => {
+  it("should return isError for invalid input", async () => {
+    const result = await client.callTool({
+      name: "query_db",
+      arguments: { sql: "DELETE FROM users" },
+    });
+    expect(result.isError).toBe(true);
+    expect(result.content[0].text).toContain("Only SELECT queries");
+  });
+
+  it("should handle tool not found", async () => {
+    await expect(
+      client.callTool({ name: "nonexistent", arguments: {} })
+    ).rejects.toThrow();
+  });
+});
+```
+
+## Integration Testing
+
+### Full Server Integration Test (Python)
+
+```python
+import pytest
+import asyncio
+from mcp import ClientSession
+from mcp.client.stdio import stdio_client
+
+@pytest.mark.asyncio
+async def test_server_integration():
+    """Test the full server by spawning it as a subprocess."""
+    async with stdio_client(
+        command="uv",
+        args=["run", "python", "server.py"],
+        env={"API_KEY": "test-key"},
+    ) as (read, write):
+        async with ClientSession(read, write) as session:
+            # Initialize
+            await session.initialize()
+
+            # List tools
+            tools = await session.list_tools()
+            tool_names = [t.name for t in tools.tools]
+            assert "search" in tool_names
+
+            # Call a tool
+            result = await session.call_tool("search", {"query": "test"})
+            assert len(result.content) > 0
+            assert result.content[0].type == "text"
+
+            # List resources
+            resources = await session.list_resources()
+            assert len(resources.resources) > 0
+
+            # Read a resource
+            content = await session.read_resource("config://app")
+            assert len(content.contents) > 0
+```
+
+### Integration Test with HTTP Transport
+
+```python
+import pytest
+import httpx
+import subprocess
+import time
+
+@pytest.fixture(scope="module")
+def server_process():
+    """Start the MCP server as a subprocess."""
+    proc = subprocess.Popen(
+        ["uv", "run", "python", "server.py", "--transport", "streamable-http", "--port", "8765"],
+        env={**os.environ, "API_KEY": "test-key"},
+    )
+    time.sleep(2)  # Wait for server to start
+    yield proc
+    proc.terminate()
+    proc.wait()
+
+@pytest.mark.asyncio
+async def test_http_server(server_process):
+    """Test the server via HTTP transport."""
+    async with httpx.AsyncClient(base_url="http://localhost:8765") as client:
+        # Initialize
+        resp = await client.post("/mcp", json={
+            "jsonrpc": "2.0",
+            "id": 1,
+            "method": "initialize",
+            "params": {
+                "protocolVersion": "2025-03-26",
+                "capabilities": {},
+                "clientInfo": {"name": "test", "version": "1.0.0"},
+            },
+        })
+        assert resp.status_code == 200
+        result = resp.json()["result"]
+        assert "capabilities" in result
+
+        # List tools
+        resp = await client.post("/mcp", json={
+            "jsonrpc": "2.0",
+            "id": 2,
+            "method": "tools/list",
+        })
+        tools = resp.json()["result"]["tools"]
+        assert len(tools) > 0
+```
+
+## Mock Clients
+
+### Python Mock Client
+
+```python
+from unittest.mock import AsyncMock
+from mcp.types import TextContent, CallToolResult
+
+def create_mock_session():
+    """Create a mock MCP session for testing."""
+    session = AsyncMock()
+
+    # Mock tool listing
+    session.list_tools.return_value = MockToolList(tools=[
+        MockTool(name="search", description="Search docs", inputSchema={
+            "type": "object",
+            "properties": {"query": {"type": "string"}},
+            "required": ["query"],
+        }),
+    ])
+
+    # Mock tool calls
+    async def mock_call_tool(name, arguments):
+        if name == "search":
+            return CallToolResult(
+                content=[TextContent(type="text", text="Mock result for: " + arguments["query"])]
+            )
+        raise ValueError(f"Unknown tool: {name}")
+
+    session.call_tool = AsyncMock(side_effect=mock_call_tool)
+    return session
+```
+
+## Protocol Debugging
+
+### Logging JSON-RPC Messages
+
+```python
+import logging
+import json
+
+# Enable protocol-level logging
+logging.basicConfig(level=logging.DEBUG)
+logger = logging.getLogger("mcp.protocol")
+
+# Custom message logger
+class MessageLogger:
+    def log_request(self, method: str, params: dict, id: int):
+        logger.debug(f"→ [{id}] {method}: {json.dumps(params, indent=2)}")
+
+    def log_response(self, id: int, result: dict):
+        logger.debug(f"← [{id}] Result: {json.dumps(result, indent=2, default=str)[:500]}")
+
+    def log_notification(self, method: str, params: dict):
+        logger.debug(f"→ (notif) {method}: {json.dumps(params, indent=2)}")
+
+    def log_error(self, id: int, error: dict):
+        logger.error(f"← [{id}] Error: {json.dumps(error, indent=2)}")
+```
+
+### Capturing Messages for Replay
+
+```python
+import json
+from datetime import datetime
+
+class MessageCapture:
+    """Capture MCP messages for debugging and replay."""
+
+    def __init__(self, output_file: str = "mcp_capture.jsonl"):
+        self.output_file = output_file
+        self.messages = []
+
+    def capture(self, direction: str, message: dict):
+        entry = {
+            "timestamp": datetime.utcnow().isoformat(),
+            "direction": direction,  # "sent" or "received"
+            "message": message,
+        }
+        self.messages.append(entry)
+        with open(self.output_file, "a") as f:
+            f.write(json.dumps(entry) + "\n")
+
+    def replay(self) -> list[dict]:
+        """Load captured messages for analysis."""
+        with open(self.output_file) as f:
+            return [json.loads(line) for line in f]
+```
+
+### Common Protocol Issues
+
+| Issue | Symptom | Diagnosis |
+|-------|---------|-----------|
+| Missing `jsonrpc` field | Server returns parse error | Check all messages include `"jsonrpc": "2.0"` |
+| Wrong method name | Method not found error | Verify against spec: `tools/call` not `tool/call` |
+| Missing `id` on request | No response received | All requests need unique `id`; notifications don't |
+| `params` vs `arguments` | Tool gets empty args | `tools/call` uses `params.arguments` for tool args |
+| Content format wrong | Client shows raw object | Must be `[{"type": "text", "text": "..."}]` |
+| Protocol version mismatch | Initialize fails | Use `"2025-03-26"` (check spec for latest) |
+
+## Common Issues and Solutions
+
+### Server Not Starting
+
+```bash
+# Check 1: Can you run the server directly?
+uv run python server.py
+# If this fails, fix the Python/dependency issues first
+
+# Check 2: Does the command path exist?
+which uv    # or: which python, which npx
+# Ensure the command is on PATH
+
+# Check 3: Are dependencies installed?
+cd /path/to/server && uv pip list | grep mcp
+
+# Check 4: Check stderr for errors (stdio servers)
+# Add to server.py:
+import sys
+print("Server starting...", file=sys.stderr)
+```
+
+### Tool Not Appearing in Client
+
+```python
+# Check 1: Does list_tools work?
+# Use MCP Inspector to verify
+npx @modelcontextprotocol/inspector -- uv run python server.py
+
+# Check 2: Is the tool registered correctly?
+# Verify with a simple test:
+@mcp.tool()
+def test_tool() -> str:
+    """A test tool that always works."""
+    return "Tool is working!"
+
+# Check 3: Invalid inputSchema
+# Ensure schema is valid JSON Schema
+# Common mistake: using Python types instead of JSON Schema types
+# BAD: {"type": "str"}
+# GOOD: {"type": "string"}
+```
+
+### Auth Failures
+
+```python
+# Check 1: Are env vars set in the CLIENT config, not just your shell?
+# Claude Desktop reads env from claude_desktop_config.json, NOT your shell profile
+
+# Check 2: Verify env vars are accessible
+@mcp.tool()
+def debug_env() -> str:
+    """Show environment variables (for debugging only)."""
+    import os
+    return json.dumps({
+        k: v[:5] + "..." if len(v) > 5 else v
+        for k, v in os.environ.items()
+        if k.startswith("MY_")  # Only show your app's vars
+    })
+
+# Check 3: Token expiration
+# Add logging to token refresh:
+import sys
+print(f"Token expires at: {expires_at}", file=sys.stderr)
+```
+
+### Timeout Errors
+
+```python
+# Check 1: Add timeouts to all external calls
+async with httpx.AsyncClient(timeout=30.0) as client:
+    resp = await client.get(url)
+
+# Check 2: Break long operations into steps
+@mcp.tool()
+async def process_large_dataset(dataset_id: str, ctx: Context) -> str:
+    """Process a large dataset in chunks with progress."""
+    chunks = get_chunks(dataset_id)
+    results = []
+    for i, chunk in enumerate(chunks):
+        await ctx.report_progress(i, len(chunks))
+        results.append(await process_chunk(chunk))
+    return json.dumps({"processed": len(results)})
+
+# Check 3: Use streaming for large responses
+# Return a summary instead of full data
+@mcp.tool()
+def query_large_table(table: str) -> str:
+    """Query a table, returning summary + sample."""
+    count = db.count(table)
+    sample = db.query(f"SELECT * FROM {table} LIMIT 10")
+    return json.dumps({
+        "total_rows": count,
+        "sample": sample,
+        "message": f"Showing 10 of {count} rows. Use pagination for more.",
+    }, default=str)
+```
+
+### JSON Parse Errors
+
+```python
+# Check 1: Don't print to stdout in stdio servers!
+# stdout IS the protocol channel. Use stderr for logging.
+import sys
+print("Debug info", file=sys.stderr)  # Correct
+# print("Debug info")  # WRONG - corrupts protocol stream
+
+# Check 2: Ensure tool results are serializable
+@mcp.tool()
+def get_data() -> str:
+    result = db.query("SELECT * FROM users")
+    # BAD: datetime objects aren't JSON serializable by default
+    # return json.dumps(result)
+
+    # GOOD: handle non-serializable types
+    return json.dumps(result, default=str)
+```
+
+## CI Testing
+
+### GitHub Actions
+
+```yaml
+# .github/workflows/test-mcp.yml
+name: Test MCP Server
+on: [push, pull_request]
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Install uv
+        uses: astral-sh/setup-uv@v4
+
+      - name: Install dependencies
+        run: uv sync
+
+      - name: Run unit tests
+        run: uv run pytest tests/ -v
+
+      - name: Run integration tests
+        run: uv run pytest tests/integration/ -v
+        env:
+          API_KEY: ${{ secrets.TEST_API_KEY }}
+
+      - name: Test server starts
+        run: |
+          uv run python server.py &
+          SERVER_PID=$!
+          sleep 3
+          # Verify server is running
+          kill -0 $SERVER_PID 2>/dev/null && echo "Server started successfully"
+          kill $SERVER_PID
+
+      - name: Test with MCP Inspector
+        run: |
+          npx @modelcontextprotocol/inspector --test -- uv run python server.py
+```
+
+### Docker-Based Testing
+
+```dockerfile
+# Dockerfile.test
+FROM python:3.12-slim
+WORKDIR /app
+COPY . .
+RUN pip install uv && uv sync
+RUN uv run pytest tests/ -v
+```
+
+```yaml
+# docker-compose.test.yml
+services:
+  test:
+    build:
+      context: .
+      dockerfile: Dockerfile.test
+    environment:
+      - API_KEY=test-key
+      - DATABASE_URL=postgresql://postgres:test@db:5432/testdb
+    depends_on:
+      - db
+  db:
+    image: postgres:16
+    environment:
+      POSTGRES_PASSWORD: test
+      POSTGRES_DB: testdb
+```
+
+```bash
+docker compose -f docker-compose.test.yml up --build --abort-on-container-exit
+```
+
+## Performance Testing
+
+### Concurrent Tool Calls
+
+```python
+import pytest
+import asyncio
+import time
+
+@pytest.mark.asyncio
+async def test_concurrent_tool_calls():
+    """Test server handles concurrent requests correctly."""
+    async with mcp.test_client() as client:
+        start = time.time()
+
+        # Fire 20 concurrent tool calls
+        tasks = [
+            client.call_tool("search", {"query": f"test-{i}"})
+            for i in range(20)
+        ]
+        results = await asyncio.gather(*tasks)
+        elapsed = time.time() - start
+
+        assert len(results) == 20
+        assert all(len(r) > 0 for r in results)
+        print(f"20 concurrent calls completed in {elapsed:.2f}s")
+```
+
+### Large Payload Handling
+
+```python
+@pytest.mark.asyncio
+async def test_large_response():
+    """Test server handles large responses without issues."""
+    async with mcp.test_client() as client:
+        result = await client.call_tool("generate_report", {
+            "size": "large",  # Generates a multi-KB response
+        })
+        assert len(result[0].text) > 10000
+        # Verify it's valid JSON
+        data = json.loads(result[0].text)
+        assert "report" in data
+
+@pytest.mark.asyncio
+async def test_response_size_limit():
+    """Verify server truncates oversized responses."""
+    async with mcp.test_client() as client:
+        result = await client.call_tool("get_all_data", {})
+        text = result[0].text
+        # Server should paginate or truncate
+        assert len(text) < 1_000_000  # Under 1MB
+```
+
+### Memory Usage
+
+```python
+import tracemalloc
+
+@pytest.mark.asyncio
+async def test_memory_usage():
+    """Test that tool calls don't leak memory."""
+    tracemalloc.start()
+
+    async with mcp.test_client() as client:
+        snapshot1 = tracemalloc.take_snapshot()
+
+        # Run 100 tool calls
+        for i in range(100):
+            await client.call_tool("search", {"query": f"test-{i}"})
+
+        snapshot2 = tracemalloc.take_snapshot()
+        stats = snapshot2.compare_to(snapshot1, "lineno")
+
+        # Check no single allocation grew more than 10MB
+        for stat in stats[:5]:
+            assert stat.size_diff < 10 * 1024 * 1024, f"Memory leak detected: {stat}"
+
+    tracemalloc.stop()
+```
+
+## Debugging Tools
+
+### Claude Desktop Logs
+
+```bash
+# macOS
+tail -f ~/Library/Logs/Claude/mcp-server-*.log
+
+# Windows
+Get-Content "$env:APPDATA\Claude\Logs\mcp-server-*.log" -Wait
+
+# Look for:
+# - Server startup errors
+# - Tool call failures
+# - Transport disconnections
+```
+
+### Custom Debug Server
+
+Add a debug tool to your server for troubleshooting:
+
+```python
+import sys
+import os
+import platform
+
+@mcp.tool()
+def server_debug_info() -> str:
+    """Return server diagnostic information (remove in production)."""
+    return json.dumps({
+        "python_version": sys.version,
+        "platform": platform.platform(),
+        "cwd": os.getcwd(),
+        "env_vars": {k: "***" for k in os.environ if k.startswith("MY_")},
+        "pid": os.getpid(),
+        "argv": sys.argv,
+    }, indent=2)
+```
+
+### Stderr Logging for stdio Servers
+
+Since stdout is the protocol channel, use stderr for all debugging:
+
+```python
+import sys
+import logging
+
+# Configure logging to stderr
+logging.basicConfig(
+    stream=sys.stderr,
+    level=logging.DEBUG,
+    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+)
+logger = logging.getLogger("my-server")
+
+@mcp.tool()
+def my_tool(query: str) -> str:
+    logger.debug(f"my_tool called with query={query!r}")
+    try:
+        result = process(query)
+        logger.info(f"my_tool succeeded: {len(result)} chars")
+        return result
+    except Exception:
+        logger.exception("my_tool failed")
+        raise
+```
+
+## Error Reproduction
+
+### Capturing and Replaying Protocol Messages
+
+```python
+import json
+
+class ProtocolRecorder:
+    """Record MCP protocol messages for reproduction."""
+
+    def __init__(self, output_path: str = "mcp_recording.jsonl"):
+        self.output_path = output_path
+        self._file = open(output_path, "w")
+
+    def record(self, direction: str, message: dict):
+        self._file.write(json.dumps({
+            "direction": direction,
+            "message": message,
+            "timestamp": time.time(),
+        }) + "\n")
+        self._file.flush()
+
+    def close(self):
+        self._file.close()
+
+
+class ProtocolReplayer:
+    """Replay recorded messages against a server."""
+
+    def __init__(self, recording_path: str):
+        with open(recording_path) as f:
+            self.entries = [json.loads(line) for line in f]
+
+    async def replay(self, session):
+        """Replay recorded messages and compare responses."""
+        sent = [e for e in self.entries if e["direction"] == "sent"]
+        received = [e for e in self.entries if e["direction"] == "received"]
+
+        for i, entry in enumerate(sent):
+            msg = entry["message"]
+            if "id" in msg:
+                # It's a request
+                method = msg["method"]
+                params = msg.get("params", {})
+                if method == "tools/call":
+                    result = await session.call_tool(
+                        params["name"],
+                        params.get("arguments", {}),
+                    )
+                    # Compare with recorded response
+                    expected = received[i] if i < len(received) else None
+                    if expected:
+                        print(f"[{method}] Match: {result == expected['message']['result']}")
+```
+
+### Minimal Reproduction Script
+
+When filing bug reports, create a minimal reproduction:
+
+```python
+#!/usr/bin/env python3
+"""Minimal reproduction for MCP issue #XXX.
+
+Run: uv run python repro.py
+Test: npx @modelcontextprotocol/inspector -- uv run python repro.py
+"""
+from mcp.server.fastmcp import FastMCP
+
+mcp = FastMCP("repro-server")
+
+@mcp.tool()
+def trigger_bug(input: str) -> str:
+    """This tool demonstrates the bug."""
+    # Minimal code that triggers the issue
+    result = problematic_operation(input)
+    return result
+
+if __name__ == "__main__":
+    mcp.run()
+```

+ 0 - 39
skills/mcp-ops/references/testing-patterns.md

@@ -1,39 +0,0 @@
-# MCP Testing Patterns
-
-Patterns for testing MCP servers.
-
-## Manual Test Script
-
-```python
-# test_server.py
-import asyncio
-from my_server.server import app
-
-async def test_tools():
-    tools = await app.list_tools()
-    print(f"Available tools: {[t['name'] for t in tools]}")
-
-    result = await app.call_tool("my_tool", {"query": "test"})
-    print(f"Result: {result}")
-
-if __name__ == "__main__":
-    asyncio.run(test_tools())
-```
-
-## pytest with Async
-
-```python
-import pytest
-from my_server.tools import handle_search
-
-@pytest.mark.asyncio
-async def test_search_returns_results():
-    result = await handle_search({"query": "test", "limit": 5})
-    assert "content" in result
-    assert len(result["content"]) > 0
-
-@pytest.mark.asyncio
-async def test_search_handles_empty():
-    result = await handle_search({"query": "xyznonexistent123"})
-    assert result["content"][0]["text"] == "No results found"
-```

+ 847 - 0
skills/mcp-ops/references/tool-handlers.md

@@ -0,0 +1,847 @@
+# Tool Handlers
+
+## Tool Schema Design
+
+Every MCP tool has an `inputSchema` that follows JSON Schema. The schema tells the LLM what arguments the tool accepts.
+
+### Basic Schema
+
+```python
+from mcp.server.fastmcp import FastMCP
+
+mcp = FastMCP("tools-demo")
+
+# FastMCP generates the schema from type hints and docstring
+@mcp.tool()
+def search(query: str, max_results: int = 10) -> str:
+    """Search for documents matching a query.
+
+    Args:
+        query: The search query string
+        max_results: Maximum number of results to return (default: 10)
+    """
+    results = perform_search(query, limit=max_results)
+    return "\n".join(f"- {r.title}: {r.snippet}" for r in results)
+```
+
+Generated schema:
+
+```json
+{
+  "name": "search",
+  "description": "Search for documents matching a query.",
+  "inputSchema": {
+    "type": "object",
+    "properties": {
+      "query": {
+        "type": "string",
+        "description": "The search query string"
+      },
+      "max_results": {
+        "type": "integer",
+        "description": "Maximum number of results to return (default: 10)",
+        "default": 10
+      }
+    },
+    "required": ["query"]
+  }
+}
+```
+
+### Complex Schemas with Pydantic
+
+```python
+from pydantic import BaseModel, Field
+from enum import Enum
+from typing import Optional
+
+class Priority(str, Enum):
+    LOW = "low"
+    MEDIUM = "medium"
+    HIGH = "high"
+    CRITICAL = "critical"
+
+class CreateTicket(BaseModel):
+    title: str = Field(description="Short summary of the issue")
+    body: str = Field(description="Detailed description")
+    priority: Priority = Field(default=Priority.MEDIUM, description="Ticket priority level")
+    labels: list[str] = Field(default_factory=list, description="Labels to apply")
+    assignee: Optional[str] = Field(default=None, description="GitHub username to assign")
+
+@mcp.tool()
+def create_ticket(ticket: CreateTicket) -> str:
+    """Create a new support ticket."""
+    result = api.create_ticket(
+        title=ticket.title,
+        body=ticket.body,
+        priority=ticket.priority.value,
+        labels=ticket.labels,
+        assignee=ticket.assignee,
+    )
+    return f"Created ticket #{result.id}: {result.url}"
+```
+
+### TypeScript Schema with Zod
+
+```typescript
+import { z } from "zod";
+
+const PriorityEnum = z.enum(["low", "medium", "high", "critical"]);
+
+server.tool(
+  "create_ticket",
+  "Create a new support ticket",
+  {
+    title: z.string().describe("Short summary of the issue"),
+    body: z.string().describe("Detailed description"),
+    priority: PriorityEnum.default("medium").describe("Ticket priority level"),
+    labels: z.array(z.string()).default([]).describe("Labels to apply"),
+    assignee: z.string().optional().describe("GitHub username to assign"),
+  },
+  async ({ title, body, priority, labels, assignee }) => {
+    const result = await api.createTicket({ title, body, priority, labels, assignee });
+    return {
+      content: [{ type: "text", text: `Created ticket #${result.id}: ${result.url}` }],
+    };
+  }
+);
+```
+
+### Schema Best Practices
+
+| Practice | Why | Example |
+|----------|-----|---------|
+| Always add `description` to every field | LLM uses descriptions to decide what values to pass | `"description": "SQL query to execute"` |
+| Use `enum` for fixed choices | Constrains LLM to valid values | `"enum": ["asc", "desc"]` |
+| Set sensible `default` values | Reduces required arguments | `"default": 10` |
+| Mark truly required fields only | Optional fields with defaults reduce friction | Only `query` required, not `max_results` |
+| Use nested objects sparingly | Flat schemas are easier for LLMs | Prefer `title, body` over `ticket: {title, body}` |
+| Keep descriptions under 100 chars | Long descriptions waste context | "Search query" not "The string to use for searching..." |
+
+## Input Validation
+
+### Python with Pydantic (FastMCP)
+
+FastMCP automatically validates inputs against type hints:
+
+```python
+from pydantic import Field, field_validator
+
+@mcp.tool()
+def query_database(
+    sql: str,
+    limit: int = Field(default=100, ge=1, le=1000),
+) -> str:
+    """Execute a read-only SQL query.
+
+    Args:
+        sql: SQL SELECT query to execute
+        limit: Maximum rows to return (1-1000)
+    """
+    if not sql.strip().upper().startswith("SELECT"):
+        raise ValueError("Only SELECT queries are allowed")
+    results = db.execute(f"{sql} LIMIT {limit}")
+    return json.dumps(results, default=str)
+```
+
+### Custom Validators
+
+```python
+from pydantic import BaseModel, field_validator
+
+class FileReadArgs(BaseModel):
+    path: str
+    encoding: str = "utf-8"
+
+    @field_validator("path")
+    @classmethod
+    def validate_path(cls, v: str) -> str:
+        import os
+        # Prevent directory traversal
+        resolved = os.path.realpath(v)
+        allowed_root = os.path.realpath("/workspace")
+        if not resolved.startswith(allowed_root):
+            raise ValueError(f"Path must be within /workspace, got: {v}")
+        return resolved
+
+    @field_validator("encoding")
+    @classmethod
+    def validate_encoding(cls, v: str) -> str:
+        allowed = {"utf-8", "ascii", "latin-1", "utf-16"}
+        if v not in allowed:
+            raise ValueError(f"Encoding must be one of: {allowed}")
+        return v
+
+@mcp.tool()
+def read_file(args: FileReadArgs) -> str:
+    """Read a file from the workspace."""
+    with open(args.path, encoding=args.encoding) as f:
+        return f.read()
+```
+
+### TypeScript with Zod
+
+```typescript
+server.tool(
+  "query_database",
+  "Execute a read-only SQL query",
+  {
+    sql: z.string()
+      .refine((s) => s.trim().toUpperCase().startsWith("SELECT"), {
+        message: "Only SELECT queries are allowed",
+      }),
+    limit: z.number().int().min(1).max(1000).default(100),
+  },
+  async ({ sql, limit }) => {
+    const results = await db.query(`${sql} LIMIT ${limit}`);
+    return {
+      content: [{ type: "text", text: JSON.stringify(results) }],
+    };
+  }
+);
+```
+
+## Return Types
+
+### Text Content (Most Common)
+
+```python
+@mcp.tool()
+def get_user(user_id: str) -> str:
+    """Look up a user by ID."""
+    user = db.get_user(user_id)
+    return json.dumps({
+        "id": user.id,
+        "name": user.name,
+        "email": user.email,
+        "created_at": user.created_at.isoformat(),
+    }, indent=2)
+```
+
+### Image Content
+
+```python
+import base64
+
+@mcp.tool()
+def generate_chart(data: str, chart_type: str = "bar") -> list:
+    """Generate a chart image from data."""
+    # Generate chart with matplotlib
+    import matplotlib.pyplot as plt
+    import io
+
+    fig, ax = plt.subplots()
+    parsed = json.loads(data)
+    ax.bar(parsed["labels"], parsed["values"])
+    ax.set_title(parsed.get("title", "Chart"))
+
+    buf = io.BytesIO()
+    fig.savefig(buf, format="png")
+    buf.seek(0)
+    plt.close(fig)
+
+    image_b64 = base64.b64encode(buf.read()).decode("utf-8")
+
+    # Return both image and text description
+    return [
+        {"type": "image", "data": image_b64, "mimeType": "image/png"},
+        {"type": "text", "text": f"Generated {chart_type} chart with {len(parsed['labels'])} data points."},
+    ]
+```
+
+### Embedded Resources
+
+```python
+@mcp.tool()
+def analyze_file(path: str) -> list:
+    """Analyze a file and return results with the file content as an embedded resource."""
+    content = open(path).read()
+    analysis = perform_analysis(content)
+
+    return [
+        {
+            "type": "resource",
+            "resource": {
+                "uri": f"file://{path}",
+                "mimeType": "text/plain",
+                "text": content,
+            },
+        },
+        {"type": "text", "text": f"Analysis:\n{analysis}"},
+    ]
+```
+
+### Multiple Content Items
+
+```python
+@mcp.tool()
+def compare_files(path_a: str, path_b: str) -> list:
+    """Compare two files and show differences."""
+    content_a = open(path_a).read()
+    content_b = open(path_b).read()
+    diff = compute_diff(content_a, content_b)
+
+    return [
+        {"type": "text", "text": f"## File A: {path_a}\n```\n{content_a}\n```"},
+        {"type": "text", "text": f"## File B: {path_b}\n```\n{content_b}\n```"},
+        {"type": "text", "text": f"## Differences\n```diff\n{diff}\n```"},
+    ]
+```
+
+## Progress Notifications
+
+For long-running operations, report progress so the client can display updates:
+
+```python
+@mcp.tool()
+async def batch_process(items: list[str], ctx: Context) -> str:
+    """Process a batch of items with progress reporting."""
+    results = []
+    total = len(items)
+
+    for i, item in enumerate(items):
+        await ctx.report_progress(i, total)
+        await ctx.info(f"Processing item {i+1}/{total}: {item}")
+
+        try:
+            result = await process_single_item(item)
+            results.append({"item": item, "status": "success", "result": result})
+        except Exception as e:
+            results.append({"item": item, "status": "error", "error": str(e)})
+
+    await ctx.report_progress(total, total)
+
+    succeeded = sum(1 for r in results if r["status"] == "success")
+    return json.dumps({
+        "summary": f"{succeeded}/{total} items processed successfully",
+        "results": results,
+    }, indent=2)
+```
+
+## Error Responses
+
+### Returning Errors to the LLM
+
+```python
+@mcp.tool()
+def delete_item(item_id: str) -> str:
+    """Delete an item by ID."""
+    try:
+        item = db.get(item_id)
+        if item is None:
+            # Raise to signal error - FastMCP sets isError=True
+            raise ValueError(f"Item {item_id} not found")
+        if item.protected:
+            raise PermissionError(f"Item {item_id} is protected and cannot be deleted")
+        db.delete(item_id)
+        return f"Deleted item {item_id}"
+    except (ValueError, PermissionError):
+        raise  # Re-raise known errors for the LLM
+    except Exception as e:
+        raise RuntimeError(f"Failed to delete item: {e}")
+```
+
+### TypeScript Error Responses
+
+```typescript
+server.tool("delete_item", "Delete an item", { item_id: z.string() }, async ({ item_id }) => {
+  try {
+    const item = await db.get(item_id);
+    if (!item) {
+      return {
+        content: [{ type: "text", text: `Item ${item_id} not found` }],
+        isError: true,
+      };
+    }
+    await db.delete(item_id);
+    return {
+      content: [{ type: "text", text: `Deleted item ${item_id}` }],
+    };
+  } catch (error) {
+    return {
+      content: [{ type: "text", text: `Failed to delete: ${error.message}` }],
+      isError: true,
+    };
+  }
+});
+```
+
+### Error Best Practices
+
+| Practice | Why |
+|----------|-----|
+| Always include actionable message | LLM can report to user or retry differently |
+| Distinguish user errors from system errors | User errors: "Invalid SQL syntax"; system: "Database unavailable" |
+| Never expose stack traces | Security risk; use structured error messages |
+| Return `isError: true` for failures | Clients and LLMs can distinguish success from failure |
+| Log internal details server-side | Use `ctx.error()` or logging for debugging |
+
+## Tool Composition
+
+### Tools Calling Other Tools
+
+```python
+@mcp.tool()
+async def analyze_and_fix(filepath: str, ctx: Context) -> str:
+    """Analyze code for issues and apply fixes."""
+    # Read the file using a resource
+    content = await ctx.read_resource(f"file://{filepath}")
+
+    # Analyze
+    issues = analyze_code(content)
+    if not issues:
+        return "No issues found"
+
+    # Apply fixes
+    fixed = apply_fixes(content, issues)
+
+    # Write back (side effect)
+    with open(filepath, "w") as f:
+        f.write(fixed)
+
+    return f"Fixed {len(issues)} issues in {filepath}:\n" + "\n".join(
+        f"- {issue.description}" for issue in issues
+    )
+```
+
+### Dependency Injection
+
+```python
+from dataclasses import dataclass
+
+@dataclass
+class AppDependencies:
+    db: DatabasePool
+    http: httpx.AsyncClient
+    cache: dict
+
+@asynccontextmanager
+async def lifespan(server: FastMCP):
+    deps = AppDependencies(
+        db=await create_pool(),
+        http=httpx.AsyncClient(),
+        cache={},
+    )
+    server.state["deps"] = deps
+    try:
+        yield
+    finally:
+        await deps.http.aclose()
+        await deps.db.close()
+
+mcp = FastMCP("di-server", lifespan=lifespan)
+
+@mcp.tool()
+async def search_and_cache(query: str, ctx: Context) -> str:
+    """Search with caching."""
+    deps: AppDependencies = ctx.server.state["deps"]
+
+    if query in deps.cache:
+        return deps.cache[query]
+
+    results = await deps.db.fetch("SELECT * FROM docs WHERE content LIKE $1", f"%{query}%")
+    formatted = json.dumps(results, default=str)
+    deps.cache[query] = formatted
+    return formatted
+```
+
+## Batch Operations
+
+```python
+@mcp.tool()
+async def bulk_update(
+    updates: list[dict],
+    continue_on_error: bool = True,
+    ctx: Context = None,
+) -> str:
+    """Apply multiple updates, optionally continuing past errors.
+
+    Args:
+        updates: List of {id, field, value} objects
+        continue_on_error: If true, skip failed items and continue
+    """
+    results = {"succeeded": [], "failed": []}
+
+    for i, update in enumerate(updates):
+        if ctx:
+            await ctx.report_progress(i, len(updates))
+        try:
+            db.update(update["id"], {update["field"]: update["value"]})
+            results["succeeded"].append(update["id"])
+        except Exception as e:
+            if continue_on_error:
+                results["failed"].append({"id": update["id"], "error": str(e)})
+            else:
+                return json.dumps({
+                    "error": f"Failed on item {update['id']}: {e}",
+                    "completed": results["succeeded"],
+                })
+
+    return json.dumps({
+        "summary": f"{len(results['succeeded'])} succeeded, {len(results['failed'])} failed",
+        **results,
+    }, indent=2)
+```
+
+## Idempotency
+
+Design tools that are safe to retry:
+
+```python
+@mcp.tool()
+def upsert_config(key: str, value: str) -> str:
+    """Set a configuration value (idempotent - safe to retry).
+
+    Args:
+        key: Configuration key
+        value: Configuration value
+    """
+    # Use upsert instead of insert to make retries safe
+    db.execute(
+        "INSERT INTO config (key, value) VALUES ($1, $2) "
+        "ON CONFLICT (key) DO UPDATE SET value = $2",
+        key, value,
+    )
+    return f"Config {key} = {value}"
+
+@mcp.tool()
+def create_item_idempotent(idempotency_key: str, title: str, body: str) -> str:
+    """Create an item with an idempotency key (safe to retry).
+
+    Args:
+        idempotency_key: Unique key for this operation (e.g., UUID)
+        title: Item title
+        body: Item body
+    """
+    existing = db.get_by_idempotency_key(idempotency_key)
+    if existing:
+        return f"Item already exists: #{existing.id} (idempotent match)"
+    item = db.create(title=title, body=body, idempotency_key=idempotency_key)
+    return f"Created item #{item.id}"
+```
+
+## Side Effects and Confirmation
+
+### Read-Only vs Mutating Tools
+
+```python
+# Read-only: no confirmation needed
+@mcp.tool()
+def list_users(role: str = "all") -> str:
+    """List users, optionally filtered by role."""
+    users = db.list_users(role=role if role != "all" else None)
+    return json.dumps(users, default=str)
+
+# Mutating: include clear description of what will change
+@mcp.tool()
+def delete_user(user_id: str, confirm: bool = False) -> str:
+    """Permanently delete a user and all their data.
+
+    WARNING: This action is irreversible. Set confirm=true to proceed.
+
+    Args:
+        user_id: The user ID to delete
+        confirm: Must be true to actually perform the deletion
+    """
+    if not confirm:
+        user = db.get_user(user_id)
+        return (
+            f"This will permanently delete user {user.name} ({user.email}) "
+            f"and {user.data_count} associated records. "
+            f"Call again with confirm=true to proceed."
+        )
+    db.delete_user(user_id)
+    return f"User {user_id} has been permanently deleted."
+```
+
+### Dry Run Pattern
+
+```python
+@mcp.tool()
+def refactor_imports(directory: str, dry_run: bool = True) -> str:
+    """Reorganize import statements in Python files.
+
+    Args:
+        directory: Directory to process
+        dry_run: If true, show what would change without modifying files
+    """
+    changes = analyze_imports(directory)
+
+    if dry_run:
+        summary = "\n".join(f"  {c.file}: {c.description}" for c in changes)
+        return f"Dry run - {len(changes)} files would be modified:\n{summary}\n\nRun with dry_run=false to apply."
+
+    for change in changes:
+        apply_change(change)
+    return f"Applied {len(changes)} import reorganizations."
+```
+
+## Schema Evolution
+
+### Backwards-Compatible Changes
+
+```python
+# v1: Original tool
+@mcp.tool()
+def search_v1(query: str) -> str:
+    """Search documents."""
+    ...
+
+# v2: Added optional fields (backwards compatible)
+@mcp.tool()
+def search(
+    query: str,
+    max_results: int = 10,        # New in v2
+    include_archived: bool = False, # New in v2
+) -> str:
+    """Search documents with optional filters.
+
+    Args:
+        query: Search query
+        max_results: Maximum results to return
+        include_archived: Include archived documents in results
+    """
+    ...
+
+# v3: Deprecated field (still accepted, but ignored)
+@mcp.tool()
+def search(
+    query: str,
+    max_results: int = 10,
+    include_archived: bool = False,
+    sort_by: str = "relevance",  # New in v3
+    # Deprecated: use sort_by instead
+    sort_order: str | None = None,  # Deprecated in v3
+) -> str:
+    """Search documents.
+
+    Args:
+        query: Search query
+        max_results: Maximum results to return
+        include_archived: Include archived documents
+        sort_by: Sort results by: relevance, date, title
+        sort_order: DEPRECATED - use sort_by instead
+    """
+    if sort_order is not None:
+        # Handle deprecated parameter gracefully
+        sort_by = sort_order
+    ...
+```
+
+## Real-World Tool Examples
+
+### File System Tools
+
+```python
+import os
+import stat
+
+@mcp.tool()
+def read_file(path: str, encoding: str = "utf-8") -> str:
+    """Read the contents of a file.
+
+    Args:
+        path: Absolute path to the file
+        encoding: File encoding (default: utf-8)
+    """
+    path = os.path.realpath(path)
+    if not os.path.isfile(path):
+        raise FileNotFoundError(f"File not found: {path}")
+
+    file_size = os.path.getsize(path)
+    if file_size > 10 * 1024 * 1024:  # 10MB limit
+        raise ValueError(f"File too large ({file_size} bytes). Maximum is 10MB.")
+
+    with open(path, encoding=encoding) as f:
+        return f.read()
+
+@mcp.tool()
+def write_file(path: str, content: str, create_dirs: bool = False) -> str:
+    """Write content to a file.
+
+    Args:
+        path: Absolute path to write to
+        content: Content to write
+        create_dirs: Create parent directories if they don't exist
+    """
+    path = os.path.realpath(path)
+    if create_dirs:
+        os.makedirs(os.path.dirname(path), exist_ok=True)
+    with open(path, "w") as f:
+        f.write(content)
+    return f"Wrote {len(content)} bytes to {path}"
+
+@mcp.tool()
+def list_directory(path: str, show_hidden: bool = False) -> str:
+    """List files and directories at the given path.
+
+    Args:
+        path: Directory path to list
+        show_hidden: Include hidden files (starting with .)
+    """
+    path = os.path.realpath(path)
+    entries = []
+    for entry in sorted(os.listdir(path)):
+        if not show_hidden and entry.startswith("."):
+            continue
+        full_path = os.path.join(path, entry)
+        info = os.stat(full_path)
+        entry_type = "dir" if stat.S_ISDIR(info.st_mode) else "file"
+        size = info.st_size if entry_type == "file" else ""
+        entries.append(f"{'[D]' if entry_type == 'dir' else '[F]'} {entry} {size}")
+    return "\n".join(entries) if entries else "(empty directory)"
+```
+
+### Database Query Tool
+
+```python
+import sqlite3
+import json
+
+@mcp.tool()
+def query_sqlite(
+    db_path: str,
+    sql: str,
+    params: list = None,
+) -> str:
+    """Execute a read-only SQL query against a SQLite database.
+
+    Args:
+        db_path: Path to the SQLite database file
+        sql: SQL query (SELECT only)
+        params: Optional query parameters for parameterized queries
+    """
+    sql_stripped = sql.strip().upper()
+    if not sql_stripped.startswith("SELECT") and not sql_stripped.startswith("WITH"):
+        raise ValueError("Only SELECT and WITH (CTE) queries are allowed")
+
+    conn = sqlite3.connect(db_path)
+    conn.row_factory = sqlite3.Row
+    try:
+        cursor = conn.execute(sql, params or [])
+        rows = [dict(row) for row in cursor.fetchall()]
+        columns = [desc[0] for desc in cursor.description] if cursor.description else []
+        return json.dumps({
+            "columns": columns,
+            "rows": rows,
+            "row_count": len(rows),
+        }, indent=2, default=str)
+    finally:
+        conn.close()
+```
+
+### API Wrapper Tool
+
+```python
+import httpx
+import os
+
+@mcp.tool()
+async def github_search(
+    query: str,
+    search_type: str = "repositories",
+    per_page: int = 10,
+) -> str:
+    """Search GitHub for repositories, code, or issues.
+
+    Args:
+        query: GitHub search query
+        search_type: Type of search: repositories, code, issues
+        per_page: Results per page (1-100)
+    """
+    if search_type not in ("repositories", "code", "issues"):
+        raise ValueError(f"Invalid search type: {search_type}")
+    if not 1 <= per_page <= 100:
+        raise ValueError("per_page must be between 1 and 100")
+
+    token = os.environ.get("GITHUB_TOKEN")
+    headers = {"Accept": "application/vnd.github.v3+json"}
+    if token:
+        headers["Authorization"] = f"Bearer {token}"
+
+    async with httpx.AsyncClient() as client:
+        resp = await client.get(
+            f"https://api.github.com/search/{search_type}",
+            params={"q": query, "per_page": per_page},
+            headers=headers,
+            timeout=30.0,
+        )
+        resp.raise_for_status()
+        data = resp.json()
+
+    items = data.get("items", [])
+    if search_type == "repositories":
+        results = [
+            f"- [{item['full_name']}]({item['html_url']}) "
+            f"({item['stargazers_count']} stars): {item.get('description', 'No description')}"
+            for item in items
+        ]
+    elif search_type == "issues":
+        results = [
+            f"- [{item['title']}]({item['html_url']}) "
+            f"({item['state']}): {item['repository_url'].split('/')[-1]}"
+            for item in items
+        ]
+    else:
+        results = [f"- {item['path']} in {item['repository']['full_name']}" for item in items]
+
+    return f"Found {data['total_count']} results:\n" + "\n".join(results)
+```
+
+### Web Scraping Tool
+
+```python
+import httpx
+from html.parser import HTMLParser
+
+class TextExtractor(HTMLParser):
+    def __init__(self):
+        super().__init__()
+        self.text_parts = []
+        self._skip = False
+        self._skip_tags = {"script", "style", "noscript"}
+
+    def handle_starttag(self, tag, attrs):
+        if tag in self._skip_tags:
+            self._skip = True
+
+    def handle_endtag(self, tag):
+        if tag in self._skip_tags:
+            self._skip = False
+
+    def handle_data(self, data):
+        if not self._skip:
+            text = data.strip()
+            if text:
+                self.text_parts.append(text)
+
+@mcp.tool()
+async def fetch_webpage(url: str, extract_text: bool = True) -> str:
+    """Fetch a webpage and optionally extract its text content.
+
+    Args:
+        url: URL to fetch
+        extract_text: If true, extract text only; if false, return raw HTML
+    """
+    if not url.startswith(("http://", "https://")):
+        raise ValueError("URL must start with http:// or https://")
+
+    async with httpx.AsyncClient(follow_redirects=True) as client:
+        resp = await client.get(url, timeout=30.0, headers={
+            "User-Agent": "MCP-Server/1.0 (compatible; tool-fetch)"
+        })
+        resp.raise_for_status()
+
+    if not extract_text:
+        return resp.text[:50000]  # Limit raw HTML size
+
+    extractor = TextExtractor()
+    extractor.feed(resp.text)
+    text = "\n".join(extractor.text_parts)
+
+    # Truncate if too long
+    if len(text) > 30000:
+        text = text[:30000] + "\n\n[Truncated - content exceeds 30KB]"
+
+    return text
+```

+ 0 - 88
skills/mcp-ops/references/tool-patterns.md

@@ -1,88 +0,0 @@
-# MCP Tool Patterns
-
-Extended patterns for MCP tool implementation.
-
-## Tool with Validation
-
-```python
-from pydantic import BaseModel, Field
-
-class SearchInput(BaseModel):
-    query: str = Field(..., min_length=1, max_length=500)
-    limit: int = Field(default=10, ge=1, le=100)
-
-@app.call_tool()
-async def call_tool(name: str, arguments: dict):
-    if name == "search":
-        # Pydantic validates and parses
-        params = SearchInput(**arguments)
-        results = await search(params.query, params.limit)
-        return {"content": [{"type": "text", "text": json.dumps(results)}]}
-```
-
-## Tool with Error Handling
-
-```python
-@app.call_tool()
-async def call_tool(name: str, arguments: dict):
-    try:
-        if name == "fetch_data":
-            data = await fetch_data(arguments["url"])
-            return {"content": [{"type": "text", "text": data}]}
-    except httpx.HTTPStatusError as e:
-        return {
-            "content": [{"type": "text", "text": f"HTTP error: {e.response.status_code}"}],
-            "isError": True
-        }
-    except Exception as e:
-        return {
-            "content": [{"type": "text", "text": f"Error: {str(e)}"}],
-            "isError": True
-        }
-```
-
-## Multiple Tool Registration
-
-```python
-TOOLS = {
-    "list_items": {
-        "description": "List all items",
-        "schema": {"type": "object", "properties": {}},
-        "handler": handle_list_items
-    },
-    "get_item": {
-        "description": "Get specific item",
-        "schema": {
-            "type": "object",
-            "properties": {"id": {"type": "string"}},
-            "required": ["id"]
-        },
-        "handler": handle_get_item
-    },
-    "create_item": {
-        "description": "Create new item",
-        "schema": {
-            "type": "object",
-            "properties": {
-                "name": {"type": "string"},
-                "data": {"type": "object"}
-            },
-            "required": ["name"]
-        },
-        "handler": handle_create_item
-    }
-}
-
-@app.list_tools()
-async def list_tools():
-    return [
-        {"name": name, "description": t["description"], "inputSchema": t["schema"]}
-        for name, t in TOOLS.items()
-    ]
-
-@app.call_tool()
-async def call_tool(name: str, arguments: dict):
-    if name not in TOOLS:
-        raise ValueError(f"Unknown tool: {name}")
-    return await TOOLS[name]["handler"](arguments)
-```

+ 735 - 0
skills/mcp-ops/references/transport-auth.md

@@ -0,0 +1,735 @@
+# Transport and Authentication
+
+## stdio Transport
+
+### How It Works
+
+The stdio transport communicates via **stdin** (client-to-server) and **stdout** (server-to-client). Each message is a JSON-RPC 2.0 object, one per line (newline-delimited).
+
+```
+Host Process                    MCP Server Process
+┌──────────┐                    ┌──────────┐
+│          │ ─── stdin ──────→  │          │
+│  Client  │                    │  Server  │
+│          │ ←── stdout ─────── │          │
+└──────────┘                    └──────────┘
+                                stderr → logs
+```
+
+**Key characteristics:**
+- One client per server process (1:1 mapping)
+- Host manages process lifecycle (spawn, restart, kill)
+- No networking - everything is local
+- stderr is used for logging (not protocol messages)
+- Simplest transport, best for CLI tools and desktop integrations
+
+### Python stdio Server
+
+```python
+from mcp.server.fastmcp import FastMCP
+
+mcp = FastMCP("my-server")
+
+@mcp.tool()
+def hello(name: str) -> str:
+    """Say hello."""
+    return f"Hello, {name}!"
+
+if __name__ == "__main__":
+    mcp.run()  # stdio is the default transport
+```
+
+### TypeScript stdio Server
+
+```typescript
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
+
+const server = new McpServer({ name: "my-server", version: "1.0.0" });
+// ... register tools ...
+
+const transport = new StdioServerTransport();
+await server.connect(transport);
+```
+
+### Claude Desktop Configuration
+
+Location: `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%/Claude/claude_desktop_config.json` (Windows)
+
+```json
+{
+  "mcpServers": {
+    "my-python-server": {
+      "command": "uv",
+      "args": ["run", "--directory", "/absolute/path/to/server", "python", "server.py"],
+      "env": {
+        "API_KEY": "sk-your-key",
+        "LOG_LEVEL": "INFO"
+      }
+    },
+    "my-node-server": {
+      "command": "npx",
+      "args": ["tsx", "/absolute/path/to/server/index.ts"],
+      "env": {
+        "API_KEY": "sk-your-key"
+      }
+    },
+    "published-server": {
+      "command": "uvx",
+      "args": ["my-published-mcp-server"],
+      "env": {}
+    }
+  }
+}
+```
+
+### Claude Code Configuration
+
+```json
+// .claude/settings.json (project-level)
+{
+  "mcpServers": {
+    "my-server": {
+      "command": "uv",
+      "args": ["run", "--directory", "/path/to/server", "python", "server.py"],
+      "env": {
+        "API_KEY": "sk-your-key"
+      }
+    }
+  }
+}
+```
+
+CLI shortcuts:
+
+```bash
+# Add a server
+claude mcp add my-server -- uv run --directory /path/to/server python server.py
+
+# Add with environment variables
+claude mcp add my-server -e API_KEY=sk-key -- uv run python server.py
+
+# List configured servers
+claude mcp list
+
+# Remove a server
+claude mcp remove my-server
+```
+
+## SSE Transport
+
+### How It Works
+
+SSE (Server-Sent Events) uses HTTP for client-to-server requests and an EventSource stream for server-to-client messages.
+
+```
+Client                              Server
+┌──────────┐                        ┌──────────┐
+│          │ ── HTTP POST /sse ───→  │          │
+│          │ ←── SSE event stream ── │          │
+│          │                         │          │
+│          │ ── HTTP POST /msg ───→  │          │
+│          │ ←── (via SSE stream) ── │          │
+└──────────┘                        └──────────┘
+```
+
+**Key characteristics:**
+- HTTP-based, works through firewalls and proxies
+- Server pushes events to client via EventSource
+- Client sends requests via HTTP POST
+- Multiple clients can connect simultaneously
+- Good for development servers and internal tools
+
+### Python SSE Server
+
+```python
+from mcp.server.fastmcp import FastMCP
+
+mcp = FastMCP("sse-server")
+
+@mcp.tool()
+def hello(name: str) -> str:
+    return f"Hello, {name}!"
+
+if __name__ == "__main__":
+    mcp.run(transport="sse", host="0.0.0.0", port=8000)
+```
+
+### TypeScript SSE Server
+
+```typescript
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
+import express from "express";
+
+const app = express();
+const server = new McpServer({ name: "sse-server", version: "1.0.0" });
+
+// ... register tools ...
+
+app.get("/sse", async (req, res) => {
+  const transport = new SSEServerTransport("/messages", res);
+  await server.connect(transport);
+});
+
+app.post("/messages", async (req, res) => {
+  // Handle incoming messages from client
+  await transport.handlePostMessage(req, res);
+});
+
+app.listen(8000, () => console.log("SSE server running on port 8000"));
+```
+
+### SSE Reconnection
+
+SSE connections can drop. The EventSource API handles automatic reconnection:
+
+```typescript
+// Client-side: EventSource handles reconnection automatically
+const eventSource = new EventSource("http://localhost:8000/sse");
+eventSource.onopen = () => console.log("Connected");
+eventSource.onerror = (e) => console.log("Connection lost, reconnecting...");
+```
+
+Server-side, send keepalive comments to prevent connection timeout:
+
+```python
+# FastMCP handles this internally when using SSE transport
+# For custom implementations, send periodic comments:
+# : keepalive\n\n
+```
+
+## Streamable HTTP Transport
+
+### How It Works
+
+Streamable HTTP uses standard HTTP POST for requests and SSE for streaming responses. It supports both stateful (session-based) and stateless modes.
+
+```
+Client                              Server
+┌──────────┐                        ┌──────────┐
+│          │ ── POST /mcp ────────→ │          │
+│          │    (JSON-RPC request)   │          │
+│          │                         │          │
+│          │ ←── SSE stream ──────── │          │
+│          │    (JSON-RPC response)  │          │
+│          │    (+ notifications)    │          │
+└──────────┘                        └──────────┘
+
+Session management via Mcp-Session-Id header
+```
+
+**Key characteristics:**
+- Single HTTP endpoint for all communication
+- Responses can be regular HTTP or SSE streams
+- Session IDs for stateful operation, or fully stateless
+- Load balancer and CDN friendly
+- Full authentication support
+- Recommended for production deployments
+
+### Python Streamable HTTP Server
+
+```python
+from mcp.server.fastmcp import FastMCP
+
+mcp = FastMCP("http-server")
+
+@mcp.tool()
+def hello(name: str) -> str:
+    return f"Hello, {name}!"
+
+if __name__ == "__main__":
+    mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)
+```
+
+### TypeScript Streamable HTTP Server
+
+```typescript
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
+import express from "express";
+
+const app = express();
+app.use(express.json());
+
+const server = new McpServer({ name: "http-server", version: "1.0.0" });
+// ... register tools ...
+
+app.post("/mcp", async (req, res) => {
+  const transport = new StreamableHTTPServerTransport({
+    sessionIdGenerator: () => crypto.randomUUID(),
+  });
+  await server.connect(transport);
+  await transport.handleRequest(req, res);
+});
+
+app.listen(8000);
+```
+
+### Stateless Mode
+
+For serverless or horizontally scaled deployments:
+
+```typescript
+const transport = new StreamableHTTPServerTransport({
+  sessionIdGenerator: undefined,  // No session tracking
+});
+```
+
+In stateless mode:
+- No `Mcp-Session-Id` header
+- Each request is independent
+- Server reconstructs state from request context
+- Works with any load balancer without sticky sessions
+
+## Transport Selection Guide
+
+| Scenario | Transport | Why |
+|----------|-----------|-----|
+| Claude Desktop integration | stdio | Native support, simplest setup |
+| Claude Code tool | stdio | Direct process management |
+| VS Code extension backend | stdio | Process managed by extension |
+| Internal dashboard | SSE | Browser-friendly, real-time updates |
+| Development/testing | SSE | Easy to inspect with browser |
+| Production API | Streamable HTTP | Auth, scaling, load balancing |
+| Serverless (Lambda, Workers) | Streamable HTTP (stateless) | No persistent connections needed |
+| Multi-tenant SaaS | Streamable HTTP | Session isolation, auth per tenant |
+| Mobile app backend | Streamable HTTP | Standard HTTP, auth support |
+
+## Session Management
+
+### Session IDs
+
+For stateful HTTP transports, each client gets a unique session:
+
+```typescript
+const transport = new StreamableHTTPServerTransport({
+  sessionIdGenerator: () => crypto.randomUUID(),
+  // Optional: validate session IDs
+  sessionValidator: async (sessionId) => {
+    return await sessionStore.exists(sessionId);
+  },
+});
+```
+
+### Session State
+
+```python
+# Store per-session state
+class SessionState:
+    def __init__(self):
+        self.user_id: str | None = None
+        self.preferences: dict = {}
+        self.history: list = []
+
+# In FastMCP, access via context
+@mcp.tool()
+async def set_preference(key: str, value: str, ctx: Context) -> str:
+    session = ctx.request_context.session
+    if not hasattr(session, "state"):
+        session.state = SessionState()
+    session.state.preferences[key] = value
+    return f"Set {key} = {value}"
+```
+
+### Session Cleanup
+
+```python
+from contextlib import asynccontextmanager
+import asyncio
+
+@asynccontextmanager
+async def lifespan(server: FastMCP):
+    # Start cleanup task
+    cleanup_task = asyncio.create_task(cleanup_expired_sessions())
+    try:
+        yield
+    finally:
+        cleanup_task.cancel()
+
+async def cleanup_expired_sessions():
+    while True:
+        await asyncio.sleep(300)  # Every 5 minutes
+        expired = session_store.get_expired(max_age_seconds=3600)
+        for session_id in expired:
+            session_store.remove(session_id)
+```
+
+## Authentication
+
+### API Keys in Environment Variables
+
+The simplest auth pattern - pass credentials via environment configuration:
+
+```python
+import os
+
+@mcp.tool()
+async def call_api(endpoint: str) -> str:
+    """Call an authenticated API."""
+    api_key = os.environ.get("API_KEY")
+    if not api_key:
+        raise RuntimeError("API_KEY environment variable not set")
+
+    async with httpx.AsyncClient() as client:
+        resp = await client.get(
+            f"https://api.example.com/{endpoint}",
+            headers={"Authorization": f"Bearer {api_key}"},
+            timeout=30.0,
+        )
+        resp.raise_for_status()
+        return resp.text
+```
+
+Client config passes the key:
+
+```json
+{
+  "mcpServers": {
+    "api-server": {
+      "command": "python",
+      "args": ["server.py"],
+      "env": {
+        "API_KEY": "sk-your-api-key-here"
+      }
+    }
+  }
+}
+```
+
+### Bearer Token Authentication (HTTP transports)
+
+For SSE and Streamable HTTP, authenticate incoming requests:
+
+```python
+from starlette.middleware.base import BaseHTTPMiddleware
+from starlette.requests import Request
+from starlette.responses import JSONResponse
+
+class AuthMiddleware(BaseHTTPMiddleware):
+    async def dispatch(self, request: Request, call_next):
+        # Skip auth for health checks
+        if request.url.path == "/health":
+            return await call_next(request)
+
+        auth_header = request.headers.get("Authorization")
+        if not auth_header or not auth_header.startswith("Bearer "):
+            return JSONResponse({"error": "Missing or invalid Authorization header"}, status_code=401)
+
+        token = auth_header[7:]
+        if not await validate_token(token):
+            return JSONResponse({"error": "Invalid token"}, status_code=403)
+
+        # Attach user info to request state
+        request.state.user = await get_user_from_token(token)
+        return await call_next(request)
+```
+
+### OAuth2 PKCE Flow
+
+For MCP servers that need user-level authentication:
+
+```python
+import secrets
+import hashlib
+import base64
+from urllib.parse import urlencode
+
+class OAuth2PKCEFlow:
+    def __init__(self, client_id: str, auth_url: str, token_url: str, redirect_uri: str):
+        self.client_id = client_id
+        self.auth_url = auth_url
+        self.token_url = token_url
+        self.redirect_uri = redirect_uri
+
+    def generate_auth_url(self) -> tuple[str, str]:
+        """Generate authorization URL with PKCE challenge."""
+        code_verifier = secrets.token_urlsafe(64)
+        code_challenge = base64.urlsafe_b64encode(
+            hashlib.sha256(code_verifier.encode()).digest()
+        ).rstrip(b"=").decode()
+
+        params = urlencode({
+            "response_type": "code",
+            "client_id": self.client_id,
+            "redirect_uri": self.redirect_uri,
+            "code_challenge": code_challenge,
+            "code_challenge_method": "S256",
+            "scope": "read write",
+        })
+        return f"{self.auth_url}?{params}", code_verifier
+
+    async def exchange_code(self, code: str, code_verifier: str) -> dict:
+        """Exchange authorization code for tokens."""
+        async with httpx.AsyncClient() as client:
+            resp = await client.post(self.token_url, data={
+                "grant_type": "authorization_code",
+                "client_id": self.client_id,
+                "code": code,
+                "redirect_uri": self.redirect_uri,
+                "code_verifier": code_verifier,
+            })
+            resp.raise_for_status()
+            return resp.json()
+
+    async def refresh_token(self, refresh_token: str) -> dict:
+        """Refresh an expired access token."""
+        async with httpx.AsyncClient() as client:
+            resp = await client.post(self.token_url, data={
+                "grant_type": "refresh_token",
+                "client_id": self.client_id,
+                "refresh_token": refresh_token,
+            })
+            resp.raise_for_status()
+            return resp.json()
+```
+
+### Token Management
+
+```python
+import time
+import asyncio
+
+class TokenManager:
+    """Manage OAuth2 tokens with automatic refresh."""
+
+    def __init__(self, oauth: OAuth2PKCEFlow):
+        self.oauth = oauth
+        self._tokens: dict = {}
+        self._lock = asyncio.Lock()
+
+    async def get_token(self) -> str:
+        """Get a valid access token, refreshing if needed."""
+        async with self._lock:
+            if self._is_valid():
+                return self._tokens["access_token"]
+
+            if "refresh_token" in self._tokens:
+                self._tokens = await self.oauth.refresh_token(self._tokens["refresh_token"])
+                self._tokens["obtained_at"] = time.time()
+                return self._tokens["access_token"]
+
+            raise RuntimeError("No valid token available. User must re-authenticate.")
+
+    def _is_valid(self) -> bool:
+        if "access_token" not in self._tokens:
+            return False
+        expires_at = self._tokens.get("obtained_at", 0) + self._tokens.get("expires_in", 0)
+        return time.time() < expires_at - 60  # 60s buffer
+
+    def set_tokens(self, tokens: dict):
+        """Store tokens after initial authorization."""
+        self._tokens = {**tokens, "obtained_at": time.time()}
+```
+
+## Rate Limiting
+
+### Per-Client Rate Limiting
+
+```python
+import time
+from collections import defaultdict
+
+class SlidingWindowRateLimiter:
+    def __init__(self, max_requests: int, window_seconds: int):
+        self.max_requests = max_requests
+        self.window = window_seconds
+        self.requests: dict[str, list[float]] = defaultdict(list)
+
+    def allow(self, client_id: str) -> bool:
+        now = time.time()
+        # Remove expired entries
+        self.requests[client_id] = [
+            t for t in self.requests[client_id]
+            if now - t < self.window
+        ]
+        if len(self.requests[client_id]) >= self.max_requests:
+            return False
+        self.requests[client_id].append(now)
+        return True
+
+    def remaining(self, client_id: str) -> int:
+        now = time.time()
+        active = [t for t in self.requests[client_id] if now - t < self.window]
+        return max(0, self.max_requests - len(active))
+
+# Usage in tools
+rate_limiter = SlidingWindowRateLimiter(max_requests=100, window_seconds=60)
+
+@mcp.tool()
+async def rate_limited_tool(query: str, ctx: Context) -> str:
+    """A rate-limited tool."""
+    client_id = str(id(ctx.request_context.session))
+    if not rate_limiter.allow(client_id):
+        remaining_wait = rate_limiter.window
+        raise RuntimeError(f"Rate limit exceeded. Try again in {remaining_wait}s.")
+    return await process(query)
+```
+
+### Per-Tool Rate Limiting
+
+```python
+from functools import wraps
+
+def rate_limit(max_calls: int, window: int):
+    """Decorator to rate-limit individual tools."""
+    limiter = SlidingWindowRateLimiter(max_calls, window)
+
+    def decorator(func):
+        @wraps(func)
+        async def wrapper(*args, **kwargs):
+            tool_name = func.__name__
+            if not limiter.allow(tool_name):
+                raise RuntimeError(f"Tool {tool_name} rate limit exceeded ({max_calls}/{window}s)")
+            return await func(*args, **kwargs) if asyncio.iscoroutinefunction(func) else func(*args, **kwargs)
+        return wrapper
+    return decorator
+
+@mcp.tool()
+@rate_limit(max_calls=10, window=60)
+async def expensive_api_call(query: str) -> str:
+    """Call an expensive API (limited to 10 calls/minute)."""
+    ...
+```
+
+## CORS Configuration
+
+For web-based clients connecting to SSE or HTTP servers:
+
+```python
+from starlette.middleware.cors import CORSMiddleware
+
+# If using Starlette/FastAPI alongside FastMCP
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=["http://localhost:3000", "https://myapp.example.com"],
+    allow_credentials=True,
+    allow_methods=["GET", "POST", "OPTIONS"],
+    allow_headers=["Authorization", "Content-Type", "Mcp-Session-Id"],
+)
+```
+
+```typescript
+// Express CORS
+import cors from "cors";
+app.use(cors({
+  origin: ["http://localhost:3000", "https://myapp.example.com"],
+  credentials: true,
+  allowedHeaders: ["Authorization", "Content-Type", "Mcp-Session-Id"],
+}));
+```
+
+## TLS/HTTPS for Production
+
+Always use HTTPS for non-stdio transports in production:
+
+```python
+# Using uvicorn with TLS
+import uvicorn
+
+if __name__ == "__main__":
+    uvicorn.run(
+        app,
+        host="0.0.0.0",
+        port=443,
+        ssl_keyfile="/path/to/key.pem",
+        ssl_certfile="/path/to/cert.pem",
+    )
+```
+
+Or terminate TLS at a reverse proxy (recommended):
+
+```nginx
+# nginx reverse proxy for MCP server
+server {
+    listen 443 ssl;
+    server_name mcp.example.com;
+
+    ssl_certificate /etc/letsencrypt/live/mcp.example.com/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/mcp.example.com/privkey.pem;
+
+    location / {
+        proxy_pass http://localhost:8000;
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_buffering off;  # Important for SSE
+        proxy_cache off;      # Don't cache SSE streams
+    }
+}
+```
+
+## Proxy Patterns
+
+### Reverse Proxy for Multiple MCP Servers
+
+Route requests to different MCP servers based on path:
+
+```nginx
+# Serve multiple MCP servers from one domain
+server {
+    listen 443 ssl;
+    server_name mcp.example.com;
+
+    # Server A: database tools
+    location /db/ {
+        proxy_pass http://localhost:8001/;
+        proxy_buffering off;
+    }
+
+    # Server B: file system tools
+    location /files/ {
+        proxy_pass http://localhost:8002/;
+        proxy_buffering off;
+    }
+
+    # Server C: API integration tools
+    location /api/ {
+        proxy_pass http://localhost:8003/;
+        proxy_buffering off;
+    }
+}
+```
+
+### Gateway Pattern
+
+A single MCP server that routes to backend services:
+
+```python
+from mcp.server.fastmcp import FastMCP
+import httpx
+
+mcp = FastMCP("gateway")
+
+# Route tool calls to backend MCP servers
+BACKENDS = {
+    "db_": "http://localhost:8001",
+    "file_": "http://localhost:8002",
+    "api_": "http://localhost:8003",
+}
+
+@mcp.tool()
+async def gateway_call(tool_name: str, arguments: dict) -> str:
+    """Route a tool call to the appropriate backend.
+
+    Args:
+        tool_name: Full tool name (e.g., db_query, file_read)
+        arguments: Tool arguments as a JSON object
+    """
+    for prefix, backend_url in BACKENDS.items():
+        if tool_name.startswith(prefix):
+            async with httpx.AsyncClient() as client:
+                resp = await client.post(
+                    f"{backend_url}/mcp",
+                    json={
+                        "jsonrpc": "2.0",
+                        "id": 1,
+                        "method": "tools/call",
+                        "params": {"name": tool_name, "arguments": arguments},
+                    },
+                )
+                return resp.json()["result"]["content"][0]["text"]
+    raise ValueError(f"No backend found for tool: {tool_name}")
+```

+ 333 - 0
skills/monitoring-ops/SKILL.md

@@ -0,0 +1,333 @@
+---
+name: monitoring-ops
+description: "Observability patterns - metrics, logging, tracing, alerting, and infrastructure monitoring. Use for: monitoring, observability, prometheus, grafana, metrics, alerting, structured logging, distributed tracing, opentelemetry, SLO, SLI, dashboard, health check, loki, jaeger, datadog, pagerduty."
+allowed-tools: "Read Write Bash"
+related-skills: [python-observability-ops, docker-ops, ci-cd-ops, nginx-ops]
+---
+
+# Monitoring Operations
+
+Comprehensive observability patterns covering the three pillars (metrics, logging, tracing), alerting strategies, dashboard design, and infrastructure monitoring for production systems.
+
+---
+
+## Three Pillars Quick Reference
+
+Use this table to decide which observability signal fits your need:
+
+| Pillar | Best For | Tools | Data Type |
+|--------|----------|-------|-----------|
+| **Metrics** | Aggregated numeric measurements, trends, alerting on thresholds | Prometheus, Datadog, CloudWatch, StatsD | Time-series (numeric) |
+| **Logs** | Discrete events, error details, audit trails, debugging context | Loki, ELK, CloudWatch Logs, Fluentd | Unstructured/structured text |
+| **Traces** | Request flow across services, latency breakdown, dependency mapping | Jaeger, Tempo, Zipkin, Datadog APM | Span trees (structured) |
+
+**When to use which:**
+
+- **"How many requests per second?"** → Metrics (counter + rate)
+- **"Why did this specific request fail?"** → Logs (error message + stack trace)
+- **"Where is the latency in this request?"** → Traces (span waterfall)
+- **"Is the system healthy right now?"** → Metrics (gauges + alerts)
+- **"What happened at 3:42 AM?"** → Logs (timestamped event search)
+- **"Which downstream service caused the timeout?"** → Traces (span analysis)
+
+**Correlation is key:** Connect all three by embedding `trace_id` in log entries, recording exemplars in metrics, and linking trace spans to log queries.
+
+---
+
+## Metrics Type Decision Tree
+
+Use this tree to select the correct metric type:
+
+```
+What are you measuring?
+│
+├─ A count of events that only goes up?
+│  └─ COUNTER
+│     Examples: http_requests_total, errors_total, bytes_sent_total
+│     Use rate() or increase() to get per-second or per-interval values
+│     Never use a counter's raw value — it resets on restart
+│
+├─ A current value that goes up AND down?
+│  └─ GAUGE
+│     Examples: temperature_celsius, active_connections, queue_depth
+│     Use for snapshots of current state
+│     Can use avg_over_time(), max_over_time() for trends
+│
+├─ A distribution of values (latency, size)?
+│  │
+│  ├─ Need aggregatable quantiles across instances?
+│  │  └─ HISTOGRAM
+│  │     Examples: http_request_duration_seconds, response_size_bytes
+│  │     Define buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]
+│  │     Use histogram_quantile() for percentiles (p50, p95, p99)
+│  │     Aggregatable across instances (histograms can be summed)
+│  │
+│  └─ Need pre-calculated quantiles on a single instance?
+│     └─ SUMMARY
+│        Examples: go_gc_duration_seconds
+│        Pre-calculates quantiles client-side
+│        NOT aggregatable across instances
+│        Prefer histogram unless you have a specific reason
+│
+└─ None of the above?
+   └─ INFO metric (labels only, value=1)
+      Examples: build_info{version="1.2.3", commit="abc123"}
+      Use for metadata exposed as metrics
+```
+
+**Rule of thumb:** Start with counters and histograms. Add gauges for current state. Avoid summaries unless you have a compelling reason.
+
+---
+
+## Alerting Decision Tree
+
+```
+What type of alert do you need?
+│
+├─ Known threshold with a fixed boundary?
+│  └─ THRESHOLD-BASED
+│     Example: CPU > 90% for 5 minutes
+│     Pros: Simple, predictable, easy to understand
+│     Cons: Requires manual tuning, doesn't adapt to patterns
+│     Best for: Resource limits, error rate spikes, queue depth
+│
+├─ Normal behavior varies by time/season?
+│  └─ ANOMALY-BASED
+│     Example: Traffic 3 standard deviations below normal for this hour
+│     Pros: Adapts to patterns, catches novel failures
+│     Cons: Noisy during transitions, requires training data
+│     Best for: Traffic patterns, business metrics, gradual degradation
+│
+└─ Defined reliability targets?
+   └─ SLO-BASED (PREFERRED)
+      Example: Error budget burn rate > 14.4x for 1 hour
+      Pros: Aligned with user impact, reduces noise, principled
+      Cons: Requires SLI/SLO definition, more complex setup
+      Best for: User-facing services, platform reliability
+```
+
+### Severity Levels
+
+| Severity | Response | Examples | Routing |
+|----------|----------|----------|---------|
+| **Critical (P1)** | Page on-call immediately | Service down, data loss risk, security breach | PagerDuty high-urgency, phone call |
+| **Warning (P2)** | Investigate within hours | Elevated error rate, disk 80% full, SLO burn rate elevated | PagerDuty low-urgency, Slack alert channel |
+| **Info (P3)** | Review next business day | Deployment completed, certificate expiring in 30 days | Slack info channel, ticket auto-created |
+
+### When to Page vs When to Ticket
+
+**Page (wake someone up) when:**
+- Users are currently impacted
+- Data loss is occurring or imminent
+- Security incident is active
+- Error budget will be exhausted within hours
+
+**Create ticket (don't page) when:**
+- Issue is not user-facing yet
+- Automated remediation is possible
+- Degradation is slow and has runway
+- Issue is during business hours and can be triaged normally
+
+---
+
+## Structured Logging Quick Reference
+
+### Standard JSON Log Format
+
+```json
+{
+  "timestamp": "2026-03-09T14:32:01.123Z",
+  "level": "ERROR",
+  "message": "Failed to process payment",
+  "service": "payment-api",
+  "version": "1.4.2",
+  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
+  "span_id": "00f067aa0ba902b7",
+  "request_id": "req-abc123",
+  "user_id": "usr-789",
+  "error": {
+    "type": "PaymentGatewayTimeout",
+    "message": "Gateway response timeout after 30s",
+    "stack": "..."
+  },
+  "duration_ms": 30042,
+  "http": {
+    "method": "POST",
+    "path": "/api/v1/payments",
+    "status_code": 504
+  }
+}
+```
+
+### Log Level Decision Guide
+
+| Level | When to Use | Examples |
+|-------|-------------|---------|
+| **DEBUG** | Development only, verbose internal state | Variable values, SQL queries, cache hits/misses |
+| **INFO** | Normal operations worth recording | Request completed, job started/finished, config loaded |
+| **WARN** | Degraded but still functioning | Retry succeeded, fallback used, approaching limit |
+| **ERROR** | Operation failed, needs attention | Payment failed, API call error, constraint violation |
+| **FATAL** | Process cannot continue, must exit | Database unreachable at startup, invalid config, OOM |
+
+**Rules:**
+- Never log at ERROR for expected conditions (user input validation → WARN)
+- Every ERROR should be actionable — if no one will act on it, use WARN
+- DEBUG should be off in production by default
+- INFO should not be noisy — 1-5 log lines per request, not 50
+
+### Correlation IDs
+
+- Generate a `request_id` (UUID v4 or ULID) at the edge/gateway
+- Propagate through all internal services via headers (`X-Request-ID`)
+- Include `trace_id` and `span_id` from distributed tracing
+- Log all three IDs in every log entry for cross-referencing
+
+---
+
+## Distributed Tracing Quick Reference
+
+### Core Concepts
+
+- **Trace:** End-to-end journey of a request across all services
+- **Span:** A single unit of work (HTTP call, DB query, function execution)
+- **Context propagation:** Passing trace/span IDs between services via headers
+
+### W3C TraceContext Header
+
+```
+traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
+              │  │                                  │                  │
+              │  │                                  │                  └─ flags (01=sampled)
+              │  │                                  └─ parent span ID (16 hex)
+              │  └─ trace ID (32 hex)
+              └─ version (00)
+```
+
+### Sampling Strategies
+
+| Strategy | How It Works | Use When |
+|----------|--------------|----------|
+| **Head-based (ratio)** | Decide at trace start, propagate decision | Low traffic, need predictable volume |
+| **Always-on** | Sample everything | Development, low-traffic services |
+| **Parent-based** | Follow parent's sampling decision | Default for most services |
+| **Tail-based** | Decide after trace completes (at Collector) | Need error/slow traces, high traffic |
+
+**Recommendation:** Use parent-based + tail-based at the Collector. This captures all error traces and slow traces while controlling volume.
+
+### Trace ID in Logs
+
+Always include `trace_id` in structured log entries. This enables jumping from a log line to the full trace view:
+
+```
+Log entry → trace_id → Jaeger/Tempo → full request waterfall
+```
+
+---
+
+## Tool Selection Matrix
+
+| Feature | Prometheus + Grafana | Datadog | Grafana Cloud | CloudWatch |
+|---------|---------------------|---------|---------------|------------|
+| **Cost** | Free (infra costs) | $$$$ (per host/metric) | $$ (usage-based) | $$ (AWS-native) |
+| **Setup complexity** | High (self-managed) | Low (SaaS agent) | Medium (managed) | Low (AWS-native) |
+| **Metrics** | Prometheus (excellent) | Built-in (excellent) | Mimir (excellent) | Built-in (good) |
+| **Logs** | Loki (good) | Built-in (excellent) | Loki (good) | CloudWatch Logs (good) |
+| **Traces** | Jaeger/Tempo (good) | APM (excellent) | Tempo (good) | X-Ray (adequate) |
+| **Alerting** | Alertmanager (good) | Built-in (excellent) | Grafana Alerting (good) | CloudWatch Alarms (adequate) |
+| **Dashboards** | Grafana (excellent) | Built-in (excellent) | Grafana (excellent) | Dashboards (adequate) |
+| **Retention** | Configurable (unlimited) | 15 months default | Configurable | Up to 15 months |
+| **Multi-cloud** | Yes | Yes | Yes | AWS only |
+| **Best for** | Cost-conscious, control | Full-featured, enterprise | Open-source + managed | AWS-native shops |
+
+**Recommendation path:**
+- **Starting out / budget-conscious:** Prometheus + Grafana + Loki + Tempo (all free, self-hosted)
+- **Small team, want managed:** Grafana Cloud free tier (10k metrics, 50GB logs, 50GB traces)
+- **Enterprise, need everything:** Datadog (expensive but comprehensive)
+- **AWS-only shop:** CloudWatch + X-Ray (simplest if already on AWS)
+
+---
+
+## Dashboard Design
+
+### USE Method (Infrastructure)
+
+For every resource (CPU, memory, disk, network):
+
+| Signal | Question | Metric Example |
+|--------|----------|----------------|
+| **Utilization** | How busy is it? | `node_cpu_seconds_total` (% busy) |
+| **Saturation** | How overloaded is it? | `node_load1` (run queue length) |
+| **Errors** | Are there error events? | `node_network_receive_errs_total` |
+
+### RED Method (Services)
+
+For every service endpoint:
+
+| Signal | Question | Metric Example |
+|--------|----------|----------------|
+| **Rate** | How many requests per second? | `rate(http_requests_total[5m])` |
+| **Errors** | How many are failing? | `rate(http_requests_total{status=~"5.."}[5m])` |
+| **Duration** | How long do they take? | `histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m]))` |
+
+### Four Golden Signals (Google SRE)
+
+| Signal | What to Measure | Alert Threshold Guidance |
+|--------|-----------------|--------------------------|
+| **Latency** | Time to serve a request (distinguish success vs error latency) | p99 > 2x baseline |
+| **Traffic** | Demand on the system (requests/sec, sessions, transactions) | Anomaly detection |
+| **Errors** | Rate of failed requests (explicit 5xx, implicit policy violations) | > 0.1% of traffic |
+| **Saturation** | How "full" the service is (CPU, memory, queue depth) | > 80% capacity |
+
+### Dashboard Layout Best Practices
+
+1. **Top row:** Key health indicators (error rate, latency p99, availability %)
+2. **Second row:** Traffic and throughput (requests/sec, active users)
+3. **Third row:** Resource utilization (CPU, memory, disk, network)
+4. **Bottom rows:** Detailed breakdowns (by endpoint, by status code, by region)
+5. **Use variables:** Service, environment, time range as dropdown selectors
+6. **Include annotations:** Deployments, incidents, config changes as vertical markers
+
+---
+
+## Common Gotchas
+
+| Gotcha | Why It Happens | Fix |
+|--------|----------------|-----|
+| **Cardinality explosion** | Using unbounded label values (user ID, request path, query string) | Use bounded labels only; aggregate high-cardinality data in logs, not metrics |
+| **Alert fatigue** | Too many alerts, too sensitive thresholds, alerts on non-actionable symptoms | Require runbook for every alert; tune thresholds; use SLO-based alerting |
+| **Missing correlation IDs** | Logs, metrics, and traces not linked together | Include trace_id in all log entries; use exemplars in metrics |
+| **Sampling bias** | Head-based sampling drops error/slow traces at high sample rates | Use tail-based sampling at the Collector to always capture errors and slow traces |
+| **Log volume costs** | DEBUG or verbose INFO in production, logging full request/response bodies | Set production to INFO minimum; truncate large payloads; use sampling for verbose paths |
+| **Metric naming inconsistency** | Different teams use different naming conventions | Adopt OpenMetrics naming: `namespace_subsystem_unit_suffix` (e.g., `http_server_request_duration_seconds`) |
+| **Dashboard sprawl** | Everyone creates dashboards, nobody maintains them | Standardize with USE/RED templates; review quarterly; delete unused dashboards |
+| **SLO too aggressive** | Setting 99.99% availability without the budget or architecture for it | Start with 99.5% or 99.9%; tighten only when consistently meeting targets with margin |
+| **Missing baseline** | Alerting on absolute thresholds without understanding normal behavior | Collect 2-4 weeks of baseline data before setting alert thresholds |
+| **Over-instrumentation** | Instrumenting every function, creating too many spans/metrics | Instrument at service boundaries; use auto-instrumentation for HTTP/DB/gRPC; add manual spans selectively |
+| **Ignoring metric staleness** | Assuming a metric that stops reporting means zero | Use `absent()` or `up == 0` to detect missing scrapers; distinguish "zero" from "not reporting" |
+| **Alerting on cause not symptom** | Alerting on CPU usage instead of user-facing error rate | Alert on symptoms (error rate, latency); use cause metrics (CPU, memory) for investigation |
+| **No retention policy** | Storing all metrics/logs at full resolution forever | Define retention tiers: 15s resolution for 2 weeks, 1m for 3 months, 5m for 1 year |
+| **Dashboard without context** | Graphs with no units, no description, no threshold lines | Add units to Y-axis, threshold lines for SLOs, panel descriptions explaining what "good" looks like |
+
+---
+
+## Reference Files
+
+| File | Contents | Lines |
+|------|----------|-------|
+| [metrics-alerting.md](references/metrics-alerting.md) | Prometheus, Grafana, OpenTelemetry metrics, SLI/SLO/SLA, alert routing, runbooks, uptime monitoring | ~650 |
+| [logging.md](references/logging.md) | Structured logging, log levels, correlation IDs, aggregation (Loki, ELK), retention, PII masking, language-specific | ~550 |
+| [tracing.md](references/tracing.md) | OpenTelemetry, spans, context propagation, sampling, Jaeger, async tracing, DB/HTTP/gRPC instrumentation | ~600 |
+| [infrastructure.md](references/infrastructure.md) | Health checks, K8s probes, Docker HEALTHCHECK, infra metrics, APM, cost optimization, incident response | ~550 |
+
+---
+
+## See Also
+
+- **docker-ops** — Container monitoring with cAdvisor, Docker stats, and health checks
+- **ci-cd-ops** — Pipeline observability, deployment tracking, build metrics
+- **nginx-ops** — Nginx access/error log parsing, request metrics, upstream monitoring
+- **python-observability-ops** — Python-specific instrumentation with structlog, opentelemetry-python
+- [OpenTelemetry documentation](https://opentelemetry.io/docs/)
+- [Prometheus best practices](https://prometheus.io/docs/practices/)
+- [Google SRE Book — Monitoring chapter](https://sre.google/sre-book/monitoring-distributed-systems/)
+- [Grafana dashboards library](https://grafana.com/grafana/dashboards/)

+ 0 - 0
skills/monitoring-ops/assets/.gitkeep


+ 1031 - 0
skills/monitoring-ops/references/infrastructure.md

@@ -0,0 +1,1031 @@
+# Infrastructure Monitoring Reference
+
+Comprehensive reference for health checks, infrastructure metrics, APM, cost optimization, capacity planning, and incident response.
+
+---
+
+## Health Checks
+
+### Types of Health Checks
+
+| Type | Question It Answers | Failure Action |
+|------|---------------------|----------------|
+| **Liveness** | Is the process alive and not deadlocked? | Restart the process |
+| **Readiness** | Can this instance serve traffic? | Remove from load balancer |
+| **Startup** | Has the process finished initializing? | Wait (don't restart yet) |
+
+### Implementation Patterns
+
+#### Basic Health Check Endpoint
+
+```go
+// Go
+type HealthStatus struct {
+    Status    string            `json:"status"`
+    Timestamp string            `json:"timestamp"`
+    Version   string            `json:"version"`
+    Checks    map[string]Check  `json:"checks"`
+}
+
+type Check struct {
+    Status  string `json:"status"`
+    Message string `json:"message,omitempty"`
+    Latency string `json:"latency,omitempty"`
+}
+
+func healthHandler(w http.ResponseWriter, r *http.Request) {
+    health := HealthStatus{
+        Status:    "ok",
+        Timestamp: time.Now().UTC().Format(time.RFC3339),
+        Version:   version,
+        Checks:    make(map[string]Check),
+    }
+
+    // Check database
+    start := time.Now()
+    if err := db.PingContext(r.Context()); err != nil {
+        health.Status = "degraded"
+        health.Checks["database"] = Check{
+            Status:  "fail",
+            Message: err.Error(),
+        }
+    } else {
+        health.Checks["database"] = Check{
+            Status:  "ok",
+            Latency: time.Since(start).String(),
+        }
+    }
+
+    // Check Redis
+    start = time.Now()
+    if err := redis.Ping(r.Context()).Err(); err != nil {
+        health.Status = "degraded"
+        health.Checks["redis"] = Check{
+            Status:  "fail",
+            Message: err.Error(),
+        }
+    } else {
+        health.Checks["redis"] = Check{
+            Status:  "ok",
+            Latency: time.Since(start).String(),
+        }
+    }
+
+    statusCode := http.StatusOK
+    if health.Status != "ok" {
+        statusCode = http.StatusServiceUnavailable
+    }
+
+    w.Header().Set("Content-Type", "application/json")
+    w.WriteHeader(statusCode)
+    json.NewEncoder(w).Encode(health)
+}
+```
+
+```python
+# Python (FastAPI)
+from fastapi import FastAPI, Response
+from datetime import datetime, timezone
+import asyncio
+
+app = FastAPI()
+
+@app.get("/health")
+async def health_check():
+    checks = {}
+    status = "ok"
+
+    # Database check
+    try:
+        start = datetime.now(timezone.utc)
+        await db.execute("SELECT 1")
+        checks["database"] = {
+            "status": "ok",
+            "latency_ms": (datetime.now(timezone.utc) - start).total_seconds() * 1000,
+        }
+    except Exception as e:
+        status = "degraded"
+        checks["database"] = {"status": "fail", "message": str(e)}
+
+    # Redis check
+    try:
+        start = datetime.now(timezone.utc)
+        await redis.ping()
+        checks["redis"] = {
+            "status": "ok",
+            "latency_ms": (datetime.now(timezone.utc) - start).total_seconds() * 1000,
+        }
+    except Exception as e:
+        status = "degraded"
+        checks["redis"] = {"status": "fail", "message": str(e)}
+
+    response_code = 200 if status == "ok" else 503
+    return Response(
+        content=json.dumps({
+            "status": status,
+            "timestamp": datetime.now(timezone.utc).isoformat(),
+            "checks": checks,
+        }),
+        status_code=response_code,
+        media_type="application/json",
+    )
+
+@app.get("/ready")
+async def readiness_check():
+    """Readiness: can we serve traffic?"""
+    try:
+        await db.execute("SELECT 1")
+        return {"status": "ready"}
+    except Exception:
+        return Response(
+            content='{"status": "not_ready"}',
+            status_code=503,
+            media_type="application/json",
+        )
+
+@app.get("/live")
+async def liveness_check():
+    """Liveness: is the process alive?"""
+    return {"status": "alive"}
+```
+
+#### Health Check Response Format
+
+```json
+{
+  "status": "ok",
+  "timestamp": "2026-03-09T14:32:01Z",
+  "version": "1.4.2",
+  "checks": {
+    "database": {
+      "status": "ok",
+      "latency_ms": 2.3
+    },
+    "redis": {
+      "status": "ok",
+      "latency_ms": 0.8
+    },
+    "external_api": {
+      "status": "degraded",
+      "message": "Elevated latency",
+      "latency_ms": 850
+    }
+  }
+}
+```
+
+### Liveness vs Readiness Decision Guide
+
+```
+Is the process able to make progress?
+├─ No (deadlocked, OOM, infinite loop)
+│  └─ Liveness check should FAIL → container gets restarted
+│
+└─ Yes, but...
+   ├─ Database is temporarily unreachable
+   │  └─ Readiness FAIL, Liveness PASS → stop sending traffic, don't restart
+   │
+   ├─ Still loading initial data/cache
+   │  └─ Startup FAIL → don't check liveness yet, wait
+   │
+   └─ Everything is fine
+      └─ All checks PASS → serve traffic normally
+```
+
+**Common mistake:** Making liveness depend on external dependencies (database, Redis). If the database is down, restarting the application won't help — it will cause a restart storm.
+
+---
+
+## Kubernetes Probes
+
+### Configuration
+
+```yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: api-server
+spec:
+  template:
+    spec:
+      containers:
+        - name: api
+          image: api-server:1.4.2
+          ports:
+            - containerPort: 8080
+
+          # Startup probe: runs first, disables liveness/readiness until passing
+          startupProbe:
+            httpGet:
+              path: /health
+              port: 8080
+            initialDelaySeconds: 5
+            periodSeconds: 5
+            failureThreshold: 30     # 30 * 5s = 150s max startup time
+            successThreshold: 1
+
+          # Liveness probe: is the process alive?
+          livenessProbe:
+            httpGet:
+              path: /live
+              port: 8080
+            initialDelaySeconds: 0    # Starts after startup probe passes
+            periodSeconds: 10
+            timeoutSeconds: 3
+            failureThreshold: 3       # 3 consecutive failures → restart
+            successThreshold: 1
+
+          # Readiness probe: can it serve traffic?
+          readinessProbe:
+            httpGet:
+              path: /ready
+              port: 8080
+            initialDelaySeconds: 0
+            periodSeconds: 5
+            timeoutSeconds: 3
+            failureThreshold: 3       # 3 failures → remove from Service
+            successThreshold: 1
+
+          resources:
+            requests:
+              cpu: 100m
+              memory: 128Mi
+            limits:
+              cpu: 500m
+              memory: 512Mi
+```
+
+### Probe Types
+
+#### HTTP GET
+
+```yaml
+livenessProbe:
+  httpGet:
+    path: /health
+    port: 8080
+    httpHeaders:
+      - name: Authorization
+        value: Bearer internal-token
+```
+
+#### TCP Socket
+
+```yaml
+# For services that don't have HTTP (databases, message brokers)
+livenessProbe:
+  tcpSocket:
+    port: 5432
+  periodSeconds: 10
+```
+
+#### Exec Command
+
+```yaml
+# Run a command inside the container
+livenessProbe:
+  exec:
+    command:
+      - /bin/sh
+      - -c
+      - pg_isready -U postgres
+  periodSeconds: 10
+```
+
+#### gRPC Health Check
+
+```yaml
+# gRPC health checking protocol
+livenessProbe:
+  grpc:
+    port: 50051
+    service: ""   # Empty string checks overall server health
+  periodSeconds: 10
+```
+
+### Probe Configuration Guidelines
+
+| Parameter | Liveness | Readiness | Startup |
+|-----------|----------|-----------|---------|
+| `initialDelaySeconds` | 0 (use startup probe) | 0 | 5-10 |
+| `periodSeconds` | 10-15 | 5-10 | 5 |
+| `timeoutSeconds` | 3-5 | 3-5 | 3-5 |
+| `failureThreshold` | 3 | 3 | 30 (generous) |
+| `successThreshold` | 1 | 1-2 | 1 |
+
+---
+
+## Docker HEALTHCHECK
+
+```dockerfile
+# Dockerfile
+FROM node:20-slim
+
+HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=60s \
+  CMD curl -f http://localhost:8080/health || exit 1
+
+# Or with wget (no curl in alpine)
+HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=60s \
+  CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
+```
+
+### docker-compose Health Check
+
+```yaml
+services:
+  api:
+    image: api-server:1.4.2
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
+      interval: 30s
+      timeout: 5s
+      retries: 3
+      start_period: 60s
+
+  worker:
+    image: worker:1.2.0
+    depends_on:
+      api:
+        condition: service_healthy
+      postgres:
+        condition: service_healthy
+
+  postgres:
+    image: postgres:16
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U postgres"]
+      interval: 10s
+      timeout: 5s
+      retries: 5
+```
+
+### Health Check Parameters
+
+| Parameter | Description | Default | Recommendation |
+|-----------|-------------|---------|----------------|
+| `interval` | Time between checks | 30s | 15-30s for critical services |
+| `timeout` | Max time for check | 30s | 3-5s (fail fast) |
+| `retries` | Failures before unhealthy | 3 | 3 (avoid flapping) |
+| `start_period` | Grace period for startup | 0s | Set to max startup time |
+
+---
+
+## Uptime Monitoring
+
+### Uptime Kuma Setup
+
+```yaml
+# docker-compose.yml
+services:
+  uptime-kuma:
+    image: louislam/uptime-kuma:1
+    restart: unless-stopped
+    ports:
+      - "3001:3001"
+    volumes:
+      - uptime-kuma-data:/app/data
+    labels:
+      - "traefik.enable=true"
+      - "traefik.http.routers.uptime.rule=Host(`status.example.com`)"
+
+volumes:
+  uptime-kuma-data:
+```
+
+**Monitor types supported:**
+- HTTP(s) — status code, keyword, response time
+- TCP — port open check
+- DNS — resolution check
+- Docker container — running status
+- gRPC — health check protocol
+- MQTT — broker connectivity
+- Ping (ICMP) — network reachability
+- Push — heartbeat endpoint (service pushes to Uptime Kuma)
+
+### Synthetic Monitoring
+
+Scripted checks that simulate real user behavior from multiple regions:
+
+```javascript
+// k6 script for synthetic monitoring
+import { check, sleep } from 'k6';
+import http from 'k6/http';
+
+export const options = {
+  scenarios: {
+    synthetic: {
+      executor: 'constant-vus',
+      vus: 1,
+      duration: '24h',
+      gracefulStop: '0s',
+    },
+  },
+  thresholds: {
+    http_req_duration: ['p(95)<500'],    // 95% under 500ms
+    http_req_failed: ['rate<0.01'],       // < 1% failure rate
+    checks: ['rate>0.99'],                // 99% checks pass
+  },
+};
+
+export default function () {
+  // Check homepage
+  let res = http.get('https://www.example.com');
+  check(res, {
+    'homepage status 200': (r) => r.status === 200,
+    'homepage loads fast': (r) => r.timings.duration < 500,
+    'homepage has title': (r) => r.body.includes('<title>'),
+  });
+
+  // Check API health
+  res = http.get('https://api.example.com/health');
+  check(res, {
+    'api health 200': (r) => r.status === 200,
+    'api reports ok': (r) => JSON.parse(r.body).status === 'ok',
+  });
+
+  // Check login flow
+  res = http.post('https://api.example.com/auth/login', JSON.stringify({
+    email: 'synthetic-user@example.com',
+    password: process.env.SYNTHETIC_PASSWORD,
+  }), { headers: { 'Content-Type': 'application/json' } });
+  check(res, {
+    'login succeeds': (r) => r.status === 200,
+    'login returns token': (r) => JSON.parse(r.body).token !== undefined,
+  });
+
+  sleep(60); // Check every 60 seconds
+}
+```
+
+### Multi-Region Monitoring
+
+| Provider | Regions | Free Tier | Notes |
+|----------|---------|-----------|-------|
+| **Uptime Kuma** | Self-hosted (1 region) | Free | Deploy in multiple regions yourself |
+| **Betteruptime** | 10+ regions | 5 monitors | Status page included |
+| **Grafana Synthetic** | 20+ regions | Part of Grafana Cloud | k6-based scripts |
+| **Datadog Synthetic** | 100+ locations | 100 API tests/month | Full browser testing |
+| **AWS CloudWatch Synthetics** | All AWS regions | Pay per run | Canary scripts |
+
+---
+
+## Infrastructure Metrics
+
+### CPU Metrics
+
+| Metric | Source | What It Shows |
+|--------|--------|---------------|
+| `node_cpu_seconds_total{mode="user"}` | node_exporter | Time in user space |
+| `node_cpu_seconds_total{mode="system"}` | node_exporter | Time in kernel space |
+| `node_cpu_seconds_total{mode="iowait"}` | node_exporter | Time waiting for I/O |
+| `node_cpu_seconds_total{mode="idle"}` | node_exporter | Idle time |
+| `node_load1` / `node_load5` / `node_load15` | node_exporter | Load average (1/5/15 min) |
+
+**Common queries:**
+
+```promql
+# CPU usage percentage (all modes except idle)
+1 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m]))
+
+# CPU usage by mode
+sum by (mode) (rate(node_cpu_seconds_total{instance="web01:9100"}[5m]))
+
+# IO wait percentage (high = disk bottleneck)
+avg by (instance) (rate(node_cpu_seconds_total{mode="iowait"}[5m]))
+
+# Load average vs CPU count
+node_load1 / count without (cpu) (node_cpu_seconds_total{mode="idle"})
+```
+
+### Memory Metrics
+
+| Metric | What It Shows |
+|--------|---------------|
+| `node_memory_MemTotal_bytes` | Total physical memory |
+| `node_memory_MemAvailable_bytes` | Memory available for applications |
+| `node_memory_Cached_bytes` | Page cache (reclaimable) |
+| `node_memory_Buffers_bytes` | Buffer cache |
+| `node_memory_SwapTotal_bytes` | Total swap |
+| `node_memory_SwapFree_bytes` | Free swap |
+
+```promql
+# Memory usage percentage
+1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)
+
+# Memory breakdown
+node_memory_MemTotal_bytes
+  - node_memory_MemAvailable_bytes
+  - node_memory_Cached_bytes
+  - node_memory_Buffers_bytes
+
+# Swap usage (any swap usage may indicate memory pressure)
+1 - (node_memory_SwapFree_bytes / node_memory_SwapTotal_bytes)
+```
+
+### Disk Metrics
+
+```promql
+# Disk usage percentage
+1 - (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes)
+
+# Disk I/O utilization (percentage of time doing I/O)
+rate(node_disk_io_time_seconds_total[5m])
+
+# Read/write throughput
+rate(node_disk_read_bytes_total[5m])
+rate(node_disk_written_bytes_total[5m])
+
+# IOPS
+rate(node_disk_reads_completed_total[5m])
+rate(node_disk_writes_completed_total[5m])
+
+# Average I/O latency
+rate(node_disk_read_time_seconds_total[5m]) / rate(node_disk_reads_completed_total[5m])
+```
+
+### Network Metrics
+
+```promql
+# Bandwidth (bytes/sec)
+rate(node_network_receive_bytes_total{device!="lo"}[5m])
+rate(node_network_transmit_bytes_total{device!="lo"}[5m])
+
+# Packet errors
+rate(node_network_receive_errs_total[5m])
+rate(node_network_transmit_errs_total[5m])
+
+# TCP connections
+node_netstat_Tcp_CurrEstab         # Current established connections
+rate(node_netstat_Tcp_ActiveOpens[5m])  # New outbound connections/sec
+rate(node_netstat_Tcp_PassiveOpens[5m]) # New inbound connections/sec
+```
+
+---
+
+## Container Metrics
+
+### cAdvisor Metrics
+
+| Metric | Description |
+|--------|-------------|
+| `container_cpu_usage_seconds_total` | Total CPU time consumed |
+| `container_cpu_cfs_throttled_periods_total` | CPU throttling events |
+| `container_memory_working_set_bytes` | Current memory (excludes cache) |
+| `container_memory_usage_bytes` | Total memory (includes cache) |
+| `container_network_receive_bytes_total` | Network inbound bytes |
+| `container_network_transmit_bytes_total` | Network outbound bytes |
+| `container_fs_usage_bytes` | Container filesystem usage |
+| `container_spec_memory_limit_bytes` | Memory limit |
+| `container_spec_cpu_quota` | CPU quota |
+
+```promql
+# Container CPU usage percentage (of limit)
+sum by (container, pod) (
+  rate(container_cpu_usage_seconds_total{container!="POD",container!=""}[5m])
+) / sum by (container, pod) (
+  container_spec_cpu_quota / container_spec_cpu_period
+)
+
+# Container memory usage percentage (of limit)
+container_memory_working_set_bytes{container!="POD",container!=""}
+/
+container_spec_memory_limit_bytes{container!="POD",container!=""} > 0
+
+# CPU throttling percentage
+sum by (container, pod) (
+  rate(container_cpu_cfs_throttled_periods_total[5m])
+) / sum by (container, pod) (
+  rate(container_cpu_cfs_periods_total[5m])
+)
+
+# OOMKill detection
+increase(kube_pod_container_status_restarts_total[1h]) > 0
+and
+kube_pod_container_status_last_terminated_reason{reason="OOMKilled"}
+```
+
+### Kubernetes Metrics (kube-state-metrics)
+
+```promql
+# Pod status
+kube_pod_status_phase{phase="Running"}
+kube_pod_status_phase{phase="Pending"}
+kube_pod_status_phase{phase="Failed"}
+
+# Deployment replicas
+kube_deployment_status_replicas_available
+kube_deployment_spec_replicas
+
+# HPA status
+kube_horizontalpodautoscaler_status_current_replicas
+kube_horizontalpodautoscaler_spec_max_replicas
+```
+
+---
+
+## Node Exporter
+
+### Setup
+
+```yaml
+# docker-compose.yml
+services:
+  node-exporter:
+    image: prom/node-exporter:v1.7.0
+    restart: unless-stopped
+    ports:
+      - "9100:9100"
+    volumes:
+      - /proc:/host/proc:ro
+      - /sys:/host/sys:ro
+      - /:/rootfs:ro
+    command:
+      - '--path.procfs=/host/proc'
+      - '--path.sysfs=/host/sys'
+      - '--path.rootfs=/rootfs'
+      - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
+```
+
+### Kubernetes DaemonSet
+
+```yaml
+apiVersion: apps/v1
+kind: DaemonSet
+metadata:
+  name: node-exporter
+  namespace: monitoring
+spec:
+  selector:
+    matchLabels:
+      app: node-exporter
+  template:
+    metadata:
+      labels:
+        app: node-exporter
+      annotations:
+        prometheus.io/scrape: "true"
+        prometheus.io/port: "9100"
+    spec:
+      hostPID: true
+      hostNetwork: true
+      containers:
+        - name: node-exporter
+          image: prom/node-exporter:v1.7.0
+          ports:
+            - containerPort: 9100
+              hostPort: 9100
+          volumeMounts:
+            - name: proc
+              mountPath: /host/proc
+              readOnly: true
+            - name: sys
+              mountPath: /host/sys
+              readOnly: true
+      volumes:
+        - name: proc
+          hostPath:
+            path: /proc
+        - name: sys
+          hostPath:
+            path: /sys
+      tolerations:
+        - effect: NoSchedule
+          operator: Exists
+```
+
+---
+
+## APM Tools
+
+### Comparison
+
+| Feature | Datadog APM | New Relic | Elastic APM | Sentry |
+|---------|-------------|-----------|-------------|--------|
+| **Type** | Full APM | Full APM | Full APM | Error tracking + perf |
+| **Pricing** | Per host ($31+/mo) | Per user + data | Free (self-host) or Cloud | Per event volume |
+| **Traces** | Yes | Yes | Yes | Transaction traces |
+| **Error tracking** | Yes | Yes | Yes | Excellent |
+| **Profiling** | Yes (continuous) | Yes | No | No |
+| **Log correlation** | Yes | Yes | Yes | Breadcrumbs |
+| **Dashboards** | Built-in | Built-in | Kibana | Limited |
+| **Setup** | Agent-based | Agent-based | Agent or OTel | SDK-based |
+| **Best for** | Enterprise, full stack | Full observability | Self-hosted, ELK users | Error-focused teams |
+
+### Sentry Error Tracking
+
+```python
+# Python
+import sentry_sdk
+from sentry_sdk.integrations.fastapi import FastApiIntegration
+
+sentry_sdk.init(
+    dsn="https://key@sentry.io/project",
+    traces_sample_rate=0.1,  # 10% of transactions
+    profiles_sample_rate=0.1,
+    environment="production",
+    release="1.4.2",
+    integrations=[FastApiIntegration()],
+)
+```
+
+```javascript
+// Node.js
+const Sentry = require('@sentry/node');
+
+Sentry.init({
+  dsn: 'https://key@sentry.io/project',
+  tracesSampleRate: 0.1,
+  environment: 'production',
+  release: '1.4.2',
+});
+```
+
+```go
+// Go
+import "github.com/getsentry/sentry-go"
+
+sentry.Init(sentry.ClientOptions{
+    Dsn:              "https://key@sentry.io/project",
+    TracesSampleRate: 0.1,
+    Environment:      "production",
+    Release:          "1.4.2",
+})
+defer sentry.Flush(2 * time.Second)
+```
+
+---
+
+## Cost Optimization
+
+### Metric Cardinality Review
+
+High cardinality is the most common cost driver in metrics systems:
+
+```promql
+# Find metrics with the most time series
+topk(20, count by (__name__) ({__name__=~".+"}))
+
+# Find labels with high cardinality
+count(group by (path) (http_requests_total))   # How many unique paths?
+count(group by (user_id) (api_calls_total))    # Unbounded!
+```
+
+**Reduction strategies:**
+1. Remove unused metrics (if nobody dashboards/alerts on it, drop it)
+2. Replace high-cardinality labels with bounded categories
+3. Use recording rules to pre-aggregate, drop raw metrics
+4. Use metric relabeling in Prometheus to drop at scrape time
+
+```yaml
+# Drop unused metrics at scrape time
+metric_relabel_configs:
+  - source_labels: [__name__]
+    regex: "go_.*"           # Drop Go runtime metrics if unused
+    action: drop
+```
+
+### Log Volume Reduction
+
+| Strategy | Savings | Implementation |
+|----------|---------|----------------|
+| Set production to INFO | 50-80% | Logger config |
+| Sample health check logs | 90% for /health | Middleware filter |
+| Truncate large payloads | 20-40% | Body size limit (4KB) |
+| Drop duplicate errors | 30-50% | Rate-limit per error type |
+| Compress in transit | 60-80% bandwidth | Enable gzip on log shipper |
+
+### Trace Sampling
+
+| Sampling Rate | Monthly Cost (est.) | Suitability |
+|---------------|---------------------|-------------|
+| 100% | $$$$ | Development, < 100 req/s |
+| 10% | $$$ | Staging, medium traffic |
+| 1% | $$ | Production, high traffic |
+| Tail-based (errors + slow) | $$ | Production (recommended) |
+| 0.1% | $ | Very high traffic (> 100k req/s) |
+
+### Retention Tiers
+
+| Tier | Metrics | Logs | Traces |
+|------|---------|------|--------|
+| Hot (0-14 days) | 15s resolution | Full fidelity | All sampled traces |
+| Warm (14-90 days) | 1m resolution | Full fidelity | Error + slow traces only |
+| Cold (90 days - 1 year) | 5m resolution | Compressed | None (rely on metrics) |
+| Archive (1-7 years) | 1h resolution | Compliance logs only | None |
+
+---
+
+## Capacity Planning
+
+### Load Testing Correlation
+
+Run load tests while monitoring infrastructure metrics to establish scaling thresholds:
+
+```
+Load Test Results:
+┌─────────┬──────────┬────────┬─────────┬──────────────┐
+│ RPS     │ p99 (ms) │ CPU %  │ Mem %   │ Error Rate   │
+├─────────┼──────────┼────────┼─────────┼──────────────┤
+│ 100     │ 45       │ 15     │ 30      │ 0%           │
+│ 500     │ 85       │ 35     │ 45      │ 0%           │
+│ 1000    │ 150      │ 55     │ 55      │ 0%           │
+│ 2000    │ 320      │ 75     │ 65      │ 0.1%         │
+│ 3000    │ 850      │ 90     │ 72      │ 1.5%         │  ← degradation
+│ 4000    │ 2500     │ 98     │ 78      │ 12%          │  ← failure
+└─────────┴──────────┴────────┴─────────┴──────────────┘
+
+Scaling trigger: 75% CPU → add instance
+Target capacity: 2x expected peak traffic
+```
+
+### Scaling Triggers
+
+```yaml
+# Kubernetes HPA
+apiVersion: autoscaling/v2
+kind: HorizontalPodAutoscaler
+metadata:
+  name: api-server
+spec:
+  scaleTargetRef:
+    apiVersion: apps/v1
+    kind: Deployment
+    name: api-server
+  minReplicas: 3
+  maxReplicas: 20
+  metrics:
+    - type: Resource
+      resource:
+        name: cpu
+        target:
+          type: Utilization
+          averageUtilization: 70    # Scale up at 70% CPU
+    - type: Resource
+      resource:
+        name: memory
+        target:
+          type: Utilization
+          averageUtilization: 80
+    - type: Pods
+      pods:
+        metric:
+          name: http_requests_per_second
+        target:
+          type: AverageValue
+          averageValue: "1000"       # Scale at 1000 RPS per pod
+  behavior:
+    scaleUp:
+      stabilizationWindowSeconds: 60
+      policies:
+        - type: Percent
+          value: 50                  # Max 50% increase per scale-up
+          periodSeconds: 60
+    scaleDown:
+      stabilizationWindowSeconds: 300  # Wait 5 min before scaling down
+      policies:
+        - type: Percent
+          value: 25
+          periodSeconds: 120
+```
+
+### Resource Forecasting
+
+```promql
+# Predict disk full in N hours
+predict_linear(node_filesystem_avail_bytes[7d], 30*24*3600) < 0
+# "Disk will be full within 30 days"
+
+# Predict memory usage trend
+predict_linear(
+  avg_over_time(container_memory_working_set_bytes[7d]),
+  30*24*3600
+)
+
+# Growth rate of database size
+rate(pg_database_size_bytes[7d])
+# Convert to "GB per month"
+rate(pg_database_size_bytes[7d]) * 86400 * 30 / 1e9
+```
+
+---
+
+## Incident Response
+
+### Incident Lifecycle
+
+```
+Detection → Triage → Mitigate → Resolve → Postmortem
+    │          │         │          │          │
+    │          │         │          │          └─ Blameless review
+    │          │         │          └─ Root cause fix deployed
+    │          │         └─ User impact reduced/eliminated
+    │          └─ Severity assigned, team engaged
+    └─ Alert fires or user reports issue
+```
+
+### Severity Classification
+
+| Severity | Impact | Response Time | Examples |
+|----------|--------|---------------|---------|
+| **SEV1 (Critical)** | Service down, data loss, security breach | < 15 minutes | Complete outage, payment processing failure |
+| **SEV2 (Major)** | Significant degradation, partial outage | < 30 minutes | One region down, 50%+ error rate |
+| **SEV3 (Minor)** | Limited impact, workaround exists | < 4 hours | Single feature broken, elevated latency |
+| **SEV4 (Low)** | Minimal impact, cosmetic | Next business day | UI glitch, non-critical alert firing |
+
+### Incident Commander Checklist
+
+```markdown
+## Initial Response (first 15 minutes)
+- [ ] Acknowledge the alert / report
+- [ ] Assess severity (SEV1-4)
+- [ ] Open incident channel (#inc-YYYYMMDD-description)
+- [ ] Page relevant team members
+- [ ] Post initial status update
+
+## Triage (15-30 minutes)
+- [ ] Identify affected services and scope
+- [ ] Check recent deployments: any changes in last 2 hours?
+- [ ] Check dashboards for anomalies
+- [ ] Check external dependencies (status pages)
+- [ ] Determine if rollback is feasible
+
+## Mitigation
+- [ ] Implement immediate fix (rollback, feature flag, scaling)
+- [ ] Verify user impact is reduced
+- [ ] Update status page
+- [ ] Communicate ETA for full resolution
+
+## Resolution
+- [ ] Confirm root cause
+- [ ] Deploy fix
+- [ ] Verify metrics return to baseline
+- [ ] Clear incident status
+- [ ] Schedule postmortem within 48 hours
+```
+
+### Postmortem Template
+
+```markdown
+# Incident Postmortem: [TITLE]
+
+**Date:** 2026-03-09
+**Duration:** 45 minutes (14:15 - 15:00 UTC)
+**Severity:** SEV2
+**Author:** [Name]
+**Status:** Complete
+
+## Summary
+One-paragraph description of what happened and impact.
+
+## Impact
+- Users affected: ~5,000
+- Revenue impact: ~$2,500
+- SLO budget consumed: 3.2 hours of the monthly 43-minute budget
+
+## Timeline (all times UTC)
+| Time | Event |
+|------|-------|
+| 14:12 | Deploy v1.4.3 to production |
+| 14:15 | Error rate alert fires (5% → 15%) |
+| 14:17 | On-call acknowledges, starts investigation |
+| 14:22 | Root cause identified: new query missing index |
+| 14:25 | Decision: rollback v1.4.3 |
+| 14:30 | Rollback complete |
+| 14:35 | Error rate returns to baseline |
+| 15:00 | All-clear declared |
+
+## Root Cause
+The v1.4.3 deployment added a new API endpoint that queried the orders
+table without an index on `user_id + created_at`. Under load, this caused
+connection pool exhaustion, which cascaded to other endpoints.
+
+## Detection
+Alert fired 3 minutes after deploy. Detection was effective.
+
+## Contributing Factors
+1. No load test for the new endpoint
+2. Missing index not caught in code review
+3. No query performance checks in CI
+
+## Action Items
+| Action | Owner | Due | Status |
+|--------|-------|-----|--------|
+| Add index on orders(user_id, created_at) | @backend | 2026-03-10 | Done |
+| Add slow query detection to CI pipeline | @platform | 2026-03-15 | TODO |
+| Add load test for new endpoints to deploy checklist | @backend | 2026-03-12 | TODO |
+| Set up query performance alerting (> 100ms avg) | @sre | 2026-03-14 | TODO |
+
+## Lessons Learned
+- What went well: Fast detection (3 min), fast rollback (8 min)
+- What went poorly: No pre-production load test caught the issue
+- Where we got lucky: Happened during business hours, not at 3 AM
+```
+
+### Communication During Incidents
+
+| Audience | Channel | Frequency | Content |
+|----------|---------|-----------|---------|
+| Engineering | Slack #incident | Real-time | Technical details, commands run |
+| Management | Slack #incidents-summary | Every 15-30 min | Impact, ETA, escalation needs |
+| Customers | Status page | Every 15-30 min | User-facing impact, workarounds |
+| Support | Slack #support-escalation | On status change | Scripted responses, known workarounds |

+ 1036 - 0
skills/monitoring-ops/references/logging.md

@@ -0,0 +1,1036 @@
+# Logging Reference
+
+Comprehensive reference for structured logging, log aggregation, correlation, and language-specific implementations.
+
+---
+
+## Structured Logging
+
+### Why Structured Logging
+
+Unstructured logs are human-readable but machine-hostile:
+
+```
+# BAD: unstructured
+2026-03-09 14:32:01 ERROR Failed to process payment for user 789: timeout after 30s
+
+# GOOD: structured JSON
+{"timestamp":"2026-03-09T14:32:01.123Z","level":"ERROR","message":"Failed to process payment","user_id":"789","error":"timeout after 30s","duration_ms":30042}
+```
+
+Structured logs enable:
+- Machine parsing and indexing
+- Filtering by any field (`user_id=789`, `level=ERROR`)
+- Aggregation and metric extraction
+- Correlation with traces via `trace_id`
+
+### Standard JSON Log Format
+
+```json
+{
+  "timestamp": "2026-03-09T14:32:01.123Z",
+  "level": "ERROR",
+  "message": "Failed to process payment",
+  "logger": "payment.processor",
+  "service": "payment-api",
+  "version": "1.4.2",
+  "environment": "production",
+  "host": "payment-api-7b4d9f-x2k9l",
+  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
+  "span_id": "00f067aa0ba902b7",
+  "request_id": "req-abc123",
+  "user_id": "usr-789",
+  "error": {
+    "type": "PaymentGatewayTimeout",
+    "message": "Gateway response timeout after 30s",
+    "stack": "PaymentGatewayTimeout: Gateway response timeout...\n  at processPayment (payment.go:142)\n  at handleRequest (handler.go:87)"
+  },
+  "context": {
+    "payment_id": "pay-456",
+    "amount_cents": 2500,
+    "currency": "USD",
+    "gateway": "stripe"
+  }
+}
+```
+
+### Key Conventions
+
+| Field | Type | Required | Notes |
+|-------|------|----------|-------|
+| `timestamp` | ISO 8601 string | Yes | Always UTC, millisecond precision |
+| `level` | string | Yes | DEBUG, INFO, WARN, ERROR, FATAL |
+| `message` | string | Yes | Human-readable, no variable interpolation in the key |
+| `service` | string | Yes | Service name (matches Prometheus job label) |
+| `version` | string | Yes | Application version or git SHA |
+| `trace_id` | string | When available | OpenTelemetry trace ID (32 hex chars) |
+| `span_id` | string | When available | OpenTelemetry span ID (16 hex chars) |
+| `request_id` | string | When available | Edge-generated request ID |
+| `error` | object | On errors | Include type, message, stack |
+| `logger` | string | Recommended | Logger name / module path |
+| `host` | string | Recommended | Hostname or pod name |
+| `environment` | string | Recommended | production, staging, development |
+
+---
+
+## Log Levels
+
+### Decision Guide
+
+```
+Is the process unable to continue?
+├─ Yes → FATAL
+│        Process must exit. Database unreachable at startup,
+│        invalid critical config, out of memory.
+│
+└─ No → Did an operation fail?
+         ├─ Yes → Is it actionable?
+         │        ├─ Yes → ERROR
+         │        │        Payment failed, API call returned 500,
+         │        │        constraint violation, file not found.
+         │        │
+         │        └─ No  → WARN
+         │                 Expected failure, retry will handle it,
+         │                 deprecated API used, nearing limit.
+         │
+         └─ No  → Is it worth recording in production?
+                   ├─ Yes → INFO
+                   │        Request handled, job completed,
+                   │        config loaded, connection established.
+                   │
+                   └─ No  → DEBUG
+                            Variable values, SQL queries,
+                            cache hit/miss, internal state.
+```
+
+### Level Details
+
+#### FATAL
+
+```json
+{"level":"FATAL","message":"Cannot connect to database","error":{"type":"ConnectionRefused","message":"dial tcp 10.0.0.5:5432: connect: connection refused"},"action":"process_exit"}
+```
+
+- Process cannot start or must terminate
+- Always followed by `os.Exit(1)` or equivalent
+- Should trigger immediate alerting
+- Very rare in well-designed systems
+
+#### ERROR
+
+```json
+{"level":"ERROR","message":"Payment processing failed","payment_id":"pay-456","user_id":"usr-789","error":{"type":"GatewayTimeout","message":"Stripe API timeout after 30s"}}
+```
+
+- Operation failed and cannot be completed
+- Someone should investigate (now or soon)
+- Every ERROR should have an associated alert or dashboard
+- **Not for:** User input validation failures (that's WARN or INFO)
+
+#### WARN
+
+```json
+{"level":"WARN","message":"Circuit breaker opened for payment gateway","gateway":"stripe","failure_count":5,"retry_after":"30s"}
+```
+
+- System is degraded but still functioning
+- Worth monitoring but not necessarily immediate action
+- Retry succeeded, fallback activated, approaching a limit
+- **Not for:** Expected user errors (wrong password → INFO)
+
+#### INFO
+
+```json
+{"level":"INFO","message":"Request completed","method":"GET","path":"/api/users","status":200,"duration_ms":45,"request_id":"req-abc123"}
+```
+
+- Normal operation, audit trail, business events
+- Should not be noisy (aim for 1-5 lines per request)
+- Deployments, configuration changes, job completions
+- **Not for:** Debugging details (use DEBUG)
+
+#### DEBUG
+
+```json
+{"level":"DEBUG","message":"Cache lookup","key":"user:789","hit":true,"ttl_remaining_ms":45200}
+```
+
+- Development and troubleshooting only
+- Disabled in production by default
+- Enable per-service or per-module when debugging
+- SQL queries, cache operations, internal state
+
+### Production Log Level Strategy
+
+```
+Production default:  INFO
+Production debug:    DEBUG (per-service, time-limited, via config change)
+Staging:             DEBUG
+Development:         DEBUG
+CI/Test:             WARN (reduce noise in test output)
+```
+
+---
+
+## Correlation IDs
+
+### Generating IDs
+
+```go
+// Go: UUID v4
+import "github.com/google/uuid"
+requestID := uuid.New().String()  // "550e8400-e29b-41d4-a716-446655440000"
+
+// Go: ULID (sortable, timestamp-prefixed)
+import "github.com/oklog/ulid/v2"
+requestID := ulid.Make().String()  // "01ARZ3NDEKTSV4RRFFQ69G5FAV"
+```
+
+```python
+# Python: UUID v4
+import uuid
+request_id = str(uuid.uuid4())
+
+# Python: ULID
+import ulid
+request_id = str(ulid.new())
+```
+
+```javascript
+// Node.js: UUID v4
+import { randomUUID } from 'crypto';
+const requestId = randomUUID();
+
+// Node.js: ULID
+import { ulid } from 'ulid';
+const requestId = ulid();
+```
+
+### Propagation Middleware
+
+#### Go (net/http)
+
+```go
+func correlationMiddleware(next http.Handler) http.Handler {
+    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+        // Get or generate request ID
+        requestID := r.Header.Get("X-Request-ID")
+        if requestID == "" {
+            requestID = uuid.New().String()
+        }
+
+        // Get trace context from OpenTelemetry
+        span := trace.SpanFromContext(r.Context())
+        traceID := span.SpanContext().TraceID().String()
+        spanID := span.SpanContext().SpanID().String()
+
+        // Add to context
+        ctx := context.WithValue(r.Context(), "request_id", requestID)
+
+        // Add to response headers
+        w.Header().Set("X-Request-ID", requestID)
+
+        // Add to logger context
+        logger := slog.With(
+            "request_id", requestID,
+            "trace_id", traceID,
+            "span_id", spanID,
+        )
+        ctx = context.WithValue(ctx, "logger", logger)
+
+        next.ServeHTTP(w, r.WithContext(ctx))
+    })
+}
+```
+
+#### Python (FastAPI)
+
+```python
+import uuid
+from contextvars import ContextVar
+from fastapi import FastAPI, Request
+from starlette.middleware.base import BaseHTTPMiddleware
+
+request_id_var: ContextVar[str] = ContextVar("request_id", default="")
+
+class CorrelationMiddleware(BaseHTTPMiddleware):
+    async def dispatch(self, request: Request, call_next):
+        request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
+        request_id_var.set(request_id)
+
+        response = await call_next(request)
+        response.headers["X-Request-ID"] = request_id
+        return response
+
+app = FastAPI()
+app.add_middleware(CorrelationMiddleware)
+```
+
+#### Node.js (Express)
+
+```javascript
+import { randomUUID } from 'crypto';
+import { AsyncLocalStorage } from 'async_hooks';
+
+const asyncLocalStorage = new AsyncLocalStorage();
+
+function correlationMiddleware(req, res, next) {
+  const requestId = req.headers['x-request-id'] || randomUUID();
+  res.setHeader('X-Request-ID', requestId);
+
+  asyncLocalStorage.run({ requestId }, () => {
+    next();
+  });
+}
+
+// Access anywhere in the request lifecycle
+function getRequestId() {
+  return asyncLocalStorage.getStore()?.requestId || 'unknown';
+}
+```
+
+### HTTP Client Propagation
+
+Always forward correlation IDs when making outbound HTTP calls:
+
+```go
+// Go
+req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
+req.Header.Set("X-Request-ID", getRequestID(ctx))
+// OpenTelemetry propagation is automatic with instrumented HTTP client
+```
+
+```python
+# Python
+headers = {"X-Request-ID": request_id_var.get()}
+response = httpx.get(url, headers=headers)
+```
+
+---
+
+## Request Context Logging
+
+### Standard Request/Response Log
+
+```go
+func loggingMiddleware(next http.Handler) http.Handler {
+    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+        start := time.Now()
+        wrapped := &responseWriter{ResponseWriter: w, statusCode: 200}
+
+        next.ServeHTTP(wrapped, r)
+
+        duration := time.Since(start)
+        logger.InfoContext(r.Context(), "Request completed",
+            "method", r.Method,
+            "path", r.URL.Path,
+            "status", wrapped.statusCode,
+            "duration_ms", duration.Milliseconds(),
+            "bytes_written", wrapped.bytesWritten,
+            "remote_addr", r.RemoteAddr,
+            "user_agent", r.UserAgent(),
+        )
+    })
+}
+```
+
+### What to Log Per Request
+
+| Field | When | Notes |
+|-------|------|-------|
+| Method, path, status, duration | Always | Core request metadata |
+| Request ID, trace ID | Always | Correlation |
+| User ID | When authenticated | For audit trail |
+| Request body | Selectively | Only for mutations, with size limit |
+| Response body | Rarely | Only for debugging, never in production |
+| Query parameters | When relevant | Sanitize sensitive params |
+| IP address | For security | Respect privacy regulations |
+| User-Agent | For analytics | Browser/client identification |
+
+### Body Size Limits
+
+```go
+// Never log unbounded request/response bodies
+const maxBodyLogSize = 4096 // 4KB
+
+func truncateBody(body []byte) string {
+    if len(body) > maxBodyLogSize {
+        return string(body[:maxBodyLogSize]) + "... [truncated]"
+    }
+    return string(body)
+}
+```
+
+---
+
+## Log Aggregation
+
+### Loki
+
+**Architecture:** Like Prometheus, but for logs. Index-free design — indexes labels only, not log content.
+
+#### Loki Configuration
+
+```yaml
+# loki-config.yml
+auth_enabled: false
+
+server:
+  http_listen_port: 3100
+
+common:
+  path_prefix: /loki
+  storage:
+    filesystem:
+      chunks_directory: /loki/chunks
+      rules_directory: /loki/rules
+  replication_factor: 1
+  ring:
+    kvstore:
+      store: inmemory
+
+schema_config:
+  configs:
+    - from: 2024-01-01
+      store: tsdb
+      object_store: filesystem
+      schema: v13
+      index:
+        prefix: index_
+        period: 24h
+
+limits_config:
+  retention_period: 30d
+  max_query_length: 721h
+  max_entries_limit_per_query: 5000
+```
+
+#### LogQL Basics
+
+```logql
+# Filter by label
+{job="api-server"} |= "error"
+
+# JSON parsing
+{job="api-server"} | json | level="ERROR"
+
+# Pattern matching
+{job="api-server"} | json | status_code >= 500
+
+# Rate of log lines (like Prometheus rate)
+rate({job="api-server"} |= "error" [5m])
+
+# Count errors by path
+sum by (path) (
+  count_over_time({job="api-server"} | json | level="ERROR" [5m])
+)
+
+# Latency percentile from log field
+quantile_over_time(0.99, {job="api-server"} | json | unwrap duration_ms [5m])
+
+# Top error messages
+topk(10,
+  sum by (message) (count_over_time({job="api-server"} | json | level="ERROR" [1h]))
+)
+```
+
+#### Label Design for Loki
+
+```yaml
+# GOOD: Low-cardinality labels
+labels:
+  job: "api-server"
+  environment: "production"
+  namespace: "default"
+
+# BAD: High-cardinality labels (will kill Loki performance)
+labels:
+  user_id: "12345"       # Millions of unique values
+  request_id: "abc-123"  # Every request is unique
+  path: "/api/users/123" # Include path in log content, not labels
+```
+
+**Rule:** Labels in Loki are for stream selection (which container/service), not for filtering log content. Use `| json | field="value"` for content filtering.
+
+#### Promtail (Log Collector)
+
+```yaml
+# promtail-config.yml
+server:
+  http_listen_port: 9080
+
+positions:
+  filename: /tmp/positions.yaml
+
+clients:
+  - url: http://loki:3100/loki/api/v1/push
+
+scrape_configs:
+  # Docker container logs
+  - job_name: docker
+    docker_sd_configs:
+      - host: unix:///var/run/docker.sock
+        refresh_interval: 5s
+    relabel_configs:
+      - source_labels: ['__meta_docker_container_name']
+        target_label: container
+      - source_labels: ['__meta_docker_container_log_stream']
+        target_label: stream
+
+  # Kubernetes pod logs
+  - job_name: kubernetes
+    kubernetes_sd_configs:
+      - role: pod
+    pipeline_stages:
+      - docker: {}
+      - json:
+          expressions:
+            level: level
+            trace_id: trace_id
+      - labels:
+          level:
+      - timestamp:
+          source: timestamp
+          format: RFC3339Nano
+```
+
+### ELK Stack (Elasticsearch, Logstash, Kibana)
+
+#### Logstash Pipeline
+
+```ruby
+# logstash.conf
+input {
+  beats {
+    port => 5044
+  }
+}
+
+filter {
+  # Parse JSON logs
+  json {
+    source => "message"
+  }
+
+  # Parse timestamp
+  date {
+    match => ["timestamp", "ISO8601"]
+    target => "@timestamp"
+  }
+
+  # Add geoip from remote_addr
+  if [remote_addr] {
+    geoip {
+      source => "remote_addr"
+    }
+  }
+
+  # Redact sensitive fields
+  mutate {
+    remove_field => ["password", "token", "authorization"]
+  }
+
+  # Parse user-agent
+  if [user_agent] {
+    useragent {
+      source => "user_agent"
+      target => "ua"
+    }
+  }
+}
+
+output {
+  elasticsearch {
+    hosts => ["elasticsearch:9200"]
+    index => "logs-%{[service]}-%{+YYYY.MM.dd}"
+  }
+}
+```
+
+### CloudWatch Logs
+
+```python
+# Python: CloudWatch Logs with structlog
+import structlog
+import watchtower
+import logging
+
+# CloudWatch handler
+cw_handler = watchtower.CloudWatchLogHandler(
+    log_group="production/api-server",
+    stream_name="{hostname}-{datetime}",
+    use_queues=True,
+    create_log_group=True,
+)
+
+# Configure structlog to output JSON
+structlog.configure(
+    processors=[
+        structlog.processors.TimeStamper(fmt="iso"),
+        structlog.processors.JSONRenderer()
+    ],
+    wrapper_class=structlog.stdlib.BoundLogger,
+    logger_factory=structlog.stdlib.LoggerFactory(),
+)
+
+logging.basicConfig(handlers=[cw_handler], level=logging.INFO)
+```
+
+#### CloudWatch Metric Filters
+
+Extract metrics from log patterns:
+
+```json
+{
+  "filterPattern": "{ $.level = \"ERROR\" }",
+  "metricTransformations": [
+    {
+      "metricName": "ErrorCount",
+      "metricNamespace": "ApiServer",
+      "metricValue": "1",
+      "defaultValue": 0
+    }
+  ]
+}
+```
+
+```json
+{
+  "filterPattern": "{ $.duration_ms > 1000 }",
+  "metricTransformations": [
+    {
+      "metricName": "SlowRequests",
+      "metricNamespace": "ApiServer",
+      "metricValue": "$.duration_ms"
+    }
+  ]
+}
+```
+
+---
+
+## Log Retention Policies
+
+### Tiered Storage Strategy
+
+| Tier | Duration | Resolution | Storage | Cost |
+|------|----------|------------|---------|------|
+| **Hot** | 0-7 days | Full fidelity | SSD / fast storage | $$$ |
+| **Warm** | 7-30 days | Full fidelity | Standard storage | $$ |
+| **Cold** | 30-90 days | Sampled or compressed | Object storage (S3) | $ |
+| **Archive** | 90 days - 7 years | Compressed | Glacier / archive | ¢ |
+
+### Compliance Retention Requirements
+
+| Regulation | Minimum Retention | Notes |
+|------------|-------------------|-------|
+| PCI DSS | 1 year (3 months immediately available) | Audit logs for card data access |
+| HIPAA | 6 years | Access logs for health data |
+| SOX | 7 years | Financial system audit trails |
+| GDPR | "No longer than necessary" | Right to erasure applies |
+| SOC 2 | 1 year typical | Security event logs |
+
+### Cost Optimization
+
+1. **Set appropriate log levels:** DEBUG off in production saves 50-80% volume
+2. **Sample verbose paths:** Log 10% of health check requests
+3. **Truncate large fields:** Limit request/response body logging to 4KB
+4. **Use log-based metrics:** Extract counts/rates, then archive raw logs
+5. **Compress early:** Enable gzip on log transport (Promtail, Fluentd)
+6. **Delete test/staging logs aggressively:** 7-day retention for non-production
+
+---
+
+## Sensitive Data Handling
+
+### PII Masking
+
+```go
+// Go: mask sensitive fields before logging
+func maskEmail(email string) string {
+    parts := strings.Split(email, "@")
+    if len(parts) != 2 {
+        return "***"
+    }
+    name := parts[0]
+    if len(name) > 2 {
+        name = name[:2] + strings.Repeat("*", len(name)-2)
+    }
+    return name + "@" + parts[1]
+}
+
+func maskCreditCard(cc string) string {
+    if len(cc) < 4 {
+        return "****"
+    }
+    return strings.Repeat("*", len(cc)-4) + cc[len(cc)-4:]
+}
+```
+
+```python
+# Python: structlog processor for PII masking
+import re
+
+SENSITIVE_KEYS = {"password", "token", "secret", "authorization", "cookie", "ssn"}
+EMAIL_PATTERN = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
+CC_PATTERN = re.compile(r"\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b")
+
+def mask_sensitive_data(logger, method_name, event_dict):
+    for key, value in list(event_dict.items()):
+        if key.lower() in SENSITIVE_KEYS:
+            event_dict[key] = "***REDACTED***"
+        elif isinstance(value, str):
+            value = EMAIL_PATTERN.sub("[EMAIL]", value)
+            value = CC_PATTERN.sub("[CREDIT_CARD]", value)
+            event_dict[key] = value
+    return event_dict
+
+structlog.configure(
+    processors=[
+        mask_sensitive_data,
+        structlog.processors.JSONRenderer(),
+    ]
+)
+```
+
+### Fields to Never Log
+
+| Field | Risk | Alternative |
+|-------|------|-------------|
+| Passwords | Credential exposure | Log "password changed" event, not the value |
+| API keys / tokens | Service compromise | Log last 4 characters only |
+| Credit card numbers | PCI violation | Log last 4 digits, masked |
+| SSN / national ID | Identity theft | Never log, even masked |
+| Full request bodies with auth | Token leakage | Strip Authorization header |
+| Database connection strings | DB credential exposure | Log host:port only |
+
+---
+
+## Log-Based Metrics
+
+### Loki Recording Rules
+
+```yaml
+# loki-rules.yml
+groups:
+  - name: log_metrics
+    interval: 1m
+    rules:
+      - record: log:errors:rate5m
+        expr: |
+          sum by (service) (
+            rate({job=~".+"} | json | level="ERROR" [5m])
+          )
+
+      - record: log:requests:duration_p99_5m
+        expr: |
+          quantile_over_time(0.99,
+            {job="api-server"} | json | unwrap duration_ms [5m]
+          ) by (service)
+```
+
+### Extracting Metrics from Logs
+
+When full metrics instrumentation isn't available, derive metrics from structured logs:
+
+```promql
+# Error rate from logs (Loki)
+sum(rate({job="api-server"} | json | level="ERROR" [5m]))
+
+# Slow request rate from logs
+sum(rate({job="api-server"} | json | duration_ms > 1000 [5m]))
+
+# Unique users from logs (approximate)
+count(
+  count by (user_id) (
+    {job="api-server"} | json | user_id != "" [1h]
+  )
+)
+```
+
+---
+
+## Language-Specific Logging
+
+### Go (slog - standard library, Go 1.21+)
+
+```go
+package main
+
+import (
+    "context"
+    "log/slog"
+    "os"
+)
+
+func main() {
+    // JSON handler for production
+    handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
+        Level: slog.LevelInfo,
+        AddSource: true,  // Add file:line to log entries
+    })
+    logger := slog.New(handler)
+    slog.SetDefault(logger)
+
+    // Basic logging
+    slog.Info("Server starting", "port", 8080, "version", "1.4.2")
+
+    // With context (includes trace_id if using OpenTelemetry bridge)
+    ctx := context.Background()
+    slog.InfoContext(ctx, "Request handled",
+        "method", "GET",
+        "path", "/api/users",
+        "status", 200,
+        "duration_ms", 45,
+    )
+
+    // Error logging with error value
+    slog.Error("Database query failed",
+        "error", err,
+        "query", "SELECT * FROM users WHERE id = $1",
+        "user_id", userID,
+    )
+
+    // Create child logger with bound attributes
+    userLogger := slog.With("user_id", "usr-789", "session_id", "sess-abc")
+    userLogger.Info("User action", "action", "login")
+}
+```
+
+### Python (structlog)
+
+```python
+import structlog
+
+structlog.configure(
+    processors=[
+        structlog.contextvars.merge_contextvars,
+        structlog.processors.add_log_level,
+        structlog.processors.TimeStamper(fmt="iso"),
+        structlog.processors.StackInfoRenderer(),
+        structlog.processors.format_exc_info,
+        structlog.processors.JSONRenderer(),
+    ],
+    wrapper_class=structlog.stdlib.BoundLogger,
+    context_class=dict,
+    logger_factory=structlog.stdlib.LoggerFactory(),
+)
+
+log = structlog.get_logger()
+
+# Basic logging
+log.info("server_starting", port=8080, version="1.4.2")
+
+# Bind context for the request
+log = log.bind(request_id="req-abc123", user_id="usr-789")
+log.info("request_handled", method="GET", path="/api/users", status=200, duration_ms=45)
+
+# Error with exception
+try:
+    process_payment(payment_id)
+except Exception:
+    log.error("payment_failed", payment_id="pay-456", exc_info=True)
+
+# Context variables (available across async calls)
+structlog.contextvars.bind_contextvars(request_id="req-abc123")
+```
+
+### Node.js (pino)
+
+```javascript
+import pino from 'pino';
+
+const logger = pino({
+  level: process.env.LOG_LEVEL || 'info',
+  timestamp: pino.stdTimeFunctions.isoTime,
+  formatters: {
+    level: (label) => ({ level: label.toUpperCase() }),
+  },
+  serializers: {
+    err: pino.stdSerializers.err,
+    req: pino.stdSerializers.req,
+    res: pino.stdSerializers.res,
+  },
+  redact: ['req.headers.authorization', 'req.headers.cookie', 'password'],
+});
+
+// Basic logging
+logger.info({ port: 8080, version: '1.4.2' }, 'Server starting');
+
+// Child logger with bound context
+const reqLogger = logger.child({ requestId: 'req-abc123', userId: 'usr-789' });
+reqLogger.info({ method: 'GET', path: '/api/users', status: 200, durationMs: 45 }, 'Request handled');
+
+// Error logging
+reqLogger.error({ err, paymentId: 'pay-456' }, 'Payment failed');
+
+// Express/Fastify integration
+import pinoHttp from 'pino-http';
+app.use(pinoHttp({ logger }));
+```
+
+### Rust (tracing crate)
+
+```rust
+use tracing::{info, error, warn, instrument, Level};
+use tracing_subscriber::{fmt, EnvFilter};
+
+fn main() {
+    // JSON subscriber for production
+    tracing_subscriber::fmt()
+        .json()
+        .with_env_filter(EnvFilter::from_default_env())
+        .with_target(true)
+        .with_thread_ids(true)
+        .with_file(true)
+        .with_line_number(true)
+        .init();
+
+    info!(port = 8080, version = "1.4.2", "Server starting");
+}
+
+#[instrument(skip(db), fields(user_id = %user_id))]
+async fn get_user(db: &Pool, user_id: &str) -> Result<User, Error> {
+    info!("Fetching user from database");
+
+    match db.query_one("SELECT * FROM users WHERE id = $1", &[&user_id]).await {
+        Ok(row) => {
+            info!("User found");
+            Ok(User::from_row(row))
+        }
+        Err(e) => {
+            error!(error = %e, "Database query failed");
+            Err(e.into())
+        }
+    }
+}
+```
+
+### Java (Logback + Structured Logging)
+
+```xml
+<!-- logback.xml -->
+<configuration>
+  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+    <encoder class="net.logstash.logback.encoder.LogstashEncoder">
+      <includeMdcKeyName>request_id</includeMdcKeyName>
+      <includeMdcKeyName>trace_id</includeMdcKeyName>
+      <includeMdcKeyName>user_id</includeMdcKeyName>
+    </encoder>
+  </appender>
+
+  <root level="INFO">
+    <appender-ref ref="STDOUT" />
+  </root>
+</configuration>
+```
+
+```java
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.slf4j.MDC;
+import net.logstash.logback.argument.StructuredArguments;
+import static net.logstash.logback.argument.StructuredArguments.*;
+
+Logger log = LoggerFactory.getLogger(PaymentService.class);
+
+// Set MDC for request context
+MDC.put("request_id", requestId);
+MDC.put("trace_id", traceId);
+MDC.put("user_id", userId);
+
+// Structured logging with key-value pairs
+log.info("Request handled", kv("method", "GET"), kv("path", "/api/users"),
+         kv("status", 200), kv("duration_ms", 45));
+
+// Error logging
+log.error("Payment failed", kv("payment_id", paymentId), kv("error", e.getMessage()), e);
+
+// Clean up MDC
+MDC.clear();
+```
+
+---
+
+## Common Patterns
+
+### Error Logging with Stack Traces
+
+Always include the full stack trace for errors, but consider truncation for very deep stacks:
+
+```go
+// Go
+slog.Error("Operation failed",
+    "error", err.Error(),
+    "stack", fmt.Sprintf("%+v", err),  // With pkgs/errors stack
+)
+```
+
+```python
+# Python - structlog handles exc_info automatically
+log.error("operation_failed", exc_info=True)
+```
+
+### Audit Logging
+
+For compliance-required operations:
+
+```json
+{
+  "timestamp": "2026-03-09T14:32:01.123Z",
+  "level": "INFO",
+  "type": "audit",
+  "action": "user.role.changed",
+  "actor": {"id": "usr-admin-1", "type": "user", "ip": "10.0.0.5"},
+  "target": {"id": "usr-789", "type": "user"},
+  "changes": {"role": {"from": "viewer", "to": "editor"}},
+  "result": "success",
+  "request_id": "req-abc123"
+}
+```
+
+### Request/Response Logging
+
+```go
+// Log request on entry, response on exit
+func loggingMiddleware(next http.Handler) http.Handler {
+    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+        start := time.Now()
+
+        // Log request (don't log body for GET, limit body size for POST)
+        slog.InfoContext(r.Context(), "Request received",
+            "method", r.Method,
+            "path", r.URL.Path,
+            "remote_addr", r.RemoteAddr,
+        )
+
+        wrapped := wrapResponseWriter(w)
+        next.ServeHTTP(wrapped, r)
+
+        slog.InfoContext(r.Context(), "Request completed",
+            "method", r.Method,
+            "path", r.URL.Path,
+            "status", wrapped.Status(),
+            "duration_ms", time.Since(start).Milliseconds(),
+            "bytes", wrapped.BytesWritten(),
+        )
+    })
+}
+```
+
+### Health Check Log Suppression
+
+Don't fill logs with health check noise:
+
+```go
+func loggingMiddleware(next http.Handler) http.Handler {
+    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+        // Skip logging for health checks
+        if r.URL.Path == "/health" || r.URL.Path == "/ready" {
+            next.ServeHTTP(w, r)
+            return
+        }
+        // ... normal logging
+    })
+}
+```

+ 1078 - 0
skills/monitoring-ops/references/metrics-alerting.md

@@ -0,0 +1,1078 @@
+# Metrics and Alerting Reference
+
+Comprehensive reference for metrics collection, visualization, alerting, SLOs, and uptime monitoring.
+
+---
+
+## Prometheus
+
+### Architecture Overview
+
+```
+┌─────────────┐     ┌─────────────┐     ┌─────────────────┐
+│ Application │────▶│  Prometheus  │────▶│  Alertmanager   │
+│  /metrics   │pull │  (TSDB)      │push │  (routing/notif) │
+└─────────────┘     └──────┬──────┘     └─────────────────┘
+                           │query
+                    ┌──────▼──────┐
+                    │   Grafana    │
+                    │ (dashboards) │
+                    └─────────────┘
+```
+
+**Key characteristics:**
+- Pull-based model (Prometheus scrapes targets)
+- Local time-series database (TSDB)
+- PromQL query language
+- Built-in alerting rules evaluated by Prometheus, routed by Alertmanager
+- Service discovery (Kubernetes, Consul, DNS, file-based, EC2)
+
+### Prometheus Configuration (prometheus.yml)
+
+```yaml
+global:
+  scrape_interval: 15s          # Default scrape interval
+  evaluation_interval: 15s      # Rule evaluation interval
+  scrape_timeout: 10s           # Per-scrape timeout
+
+# Alertmanager configuration
+alerting:
+  alertmanagers:
+    - static_configs:
+        - targets:
+            - alertmanager:9093
+
+# Rule files
+rule_files:
+  - "rules/*.yml"
+
+# Scrape targets
+scrape_configs:
+  # Self-monitoring
+  - job_name: "prometheus"
+    static_configs:
+      - targets: ["localhost:9090"]
+
+  # Application with static targets
+  - job_name: "api-server"
+    metrics_path: /metrics
+    scheme: https
+    static_configs:
+      - targets: ["api1:8080", "api2:8080"]
+        labels:
+          environment: production
+
+  # Kubernetes service discovery
+  - job_name: "kubernetes-pods"
+    kubernetes_sd_configs:
+      - role: pod
+    relabel_configs:
+      - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
+        action: keep
+        regex: true
+      - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]
+        action: replace
+        target_label: __metrics_path__
+        regex: (.+)
+      - source_labels: [__meta_kubernetes_namespace]
+        action: replace
+        target_label: namespace
+      - source_labels: [__meta_kubernetes_pod_name]
+        action: replace
+        target_label: pod
+
+  # Node exporter
+  - job_name: "node"
+    static_configs:
+      - targets: ["node-exporter:9100"]
+```
+
+### PromQL Basics
+
+#### Rate and Increase
+
+```promql
+# Per-second rate over 5 minutes (use for counters)
+rate(http_requests_total[5m])
+
+# Per-second rate for specific status codes
+rate(http_requests_total{status_code=~"5.."}[5m])
+
+# Total increase over 1 hour (use for counters)
+increase(http_requests_total[1h])
+
+# irate: instant rate using last two data points (more volatile)
+irate(http_requests_total[5m])
+```
+
+**Rule:** Always use `rate()` or `increase()` with counters. Never display raw counter values.
+
+#### Aggregation Operators
+
+```promql
+# Sum across all instances
+sum(rate(http_requests_total[5m]))
+
+# Sum by specific label
+sum by (method, path) (rate(http_requests_total[5m]))
+
+# Average across instances
+avg(node_cpu_seconds_total{mode="idle"})
+
+# Maximum value across instances
+max by (instance) (node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes)
+
+# Count number of time series
+count(up == 1)
+
+# Top 5 by value
+topk(5, rate(http_requests_total[5m]))
+
+# Bottom 5 by value
+bottomk(5, rate(http_requests_total[5m]))
+```
+
+#### Histogram Quantiles
+
+```promql
+# 99th percentile latency
+histogram_quantile(0.99,
+  sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
+)
+
+# 95th percentile latency by service
+histogram_quantile(0.95,
+  sum by (le, service) (rate(http_request_duration_seconds_bucket[5m]))
+)
+
+# 50th percentile (median)
+histogram_quantile(0.50,
+  sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
+)
+
+# Average latency from histogram
+sum(rate(http_request_duration_seconds_sum[5m]))
+/
+sum(rate(http_request_duration_seconds_count[5m]))
+```
+
+#### Useful Functions
+
+```promql
+# Detect missing metrics (target down)
+absent(up{job="api-server"})
+
+# Time since last change (staleness)
+time() - process_start_time_seconds
+
+# Predict value in 4 hours using linear regression
+predict_linear(node_filesystem_avail_bytes[6h], 4*3600)
+
+# Compare to 1 week ago
+rate(http_requests_total[5m]) / rate(http_requests_total[5m] offset 7d)
+
+# Clamping values
+clamp_min(free_disk_percentage, 0)
+clamp_max(cpu_usage_percentage, 100)
+
+# Label manipulation
+label_replace(up, "short_instance", "$1", "instance", "(.*):.*")
+```
+
+### Recording Rules
+
+Pre-compute expensive queries for dashboards and alerts:
+
+```yaml
+# rules/recording-rules.yml
+groups:
+  - name: http_request_rules
+    interval: 15s
+    rules:
+      # Pre-compute request rate by service and status
+      - record: job:http_requests:rate5m
+        expr: sum by (job, status_code) (rate(http_requests_total[5m]))
+
+      # Pre-compute error rate percentage
+      - record: job:http_request_errors:ratio5m
+        expr: |
+          sum by (job) (rate(http_requests_total{status_code=~"5.."}[5m]))
+          /
+          sum by (job) (rate(http_requests_total[5m]))
+
+      # Pre-compute p99 latency
+      - record: job:http_request_duration_seconds:p99_5m
+        expr: |
+          histogram_quantile(0.99,
+            sum by (job, le) (rate(http_request_duration_seconds_bucket[5m]))
+          )
+
+      # Pre-compute availability
+      - record: job:availability:ratio5m
+        expr: |
+          1 - (
+            sum by (job) (rate(http_requests_total{status_code=~"5.."}[5m]))
+            /
+            sum by (job) (rate(http_requests_total[5m]))
+          )
+```
+
+### Alerting Rules
+
+```yaml
+# rules/alerting-rules.yml
+groups:
+  - name: service_alerts
+    rules:
+      # High error rate
+      - alert: HighErrorRate
+        expr: job:http_request_errors:ratio5m > 0.01
+        for: 5m
+        labels:
+          severity: warning
+          team: backend
+        annotations:
+          summary: "High error rate on {{ $labels.job }}"
+          description: "Error rate is {{ $value | humanizePercentage }} (threshold: 1%)"
+          runbook_url: "https://runbooks.example.com/high-error-rate"
+          dashboard_url: "https://grafana.example.com/d/service-overview?var-service={{ $labels.job }}"
+
+      # Critical error rate
+      - alert: CriticalErrorRate
+        expr: job:http_request_errors:ratio5m > 0.05
+        for: 2m
+        labels:
+          severity: critical
+          team: backend
+        annotations:
+          summary: "Critical error rate on {{ $labels.job }}"
+          description: "Error rate is {{ $value | humanizePercentage }} (threshold: 5%)"
+          runbook_url: "https://runbooks.example.com/critical-error-rate"
+
+      # High latency
+      - alert: HighLatencyP99
+        expr: job:http_request_duration_seconds:p99_5m > 2.0
+        for: 10m
+        labels:
+          severity: warning
+        annotations:
+          summary: "P99 latency above 2s on {{ $labels.job }}"
+          description: "P99 latency is {{ $value | humanizeDuration }}"
+
+      # Target down
+      - alert: TargetDown
+        expr: up == 0
+        for: 3m
+        labels:
+          severity: critical
+        annotations:
+          summary: "Target {{ $labels.instance }} is down"
+          description: "Prometheus cannot scrape {{ $labels.job }}/{{ $labels.instance }}"
+
+  - name: infrastructure_alerts
+    rules:
+      # Disk space prediction
+      - alert: DiskWillFillIn24Hours
+        expr: |
+          predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}[6h], 24*3600) < 0
+        for: 30m
+        labels:
+          severity: warning
+        annotations:
+          summary: "Disk {{ $labels.mountpoint }} on {{ $labels.instance }} will fill within 24 hours"
+
+      # High memory usage
+      - alert: HighMemoryUsage
+        expr: |
+          (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 0.9
+        for: 10m
+        labels:
+          severity: warning
+        annotations:
+          summary: "Memory usage above 90% on {{ $labels.instance }}"
+
+      # High CPU usage
+      - alert: HighCPUUsage
+        expr: |
+          1 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) > 0.85
+        for: 15m
+        labels:
+          severity: warning
+        annotations:
+          summary: "CPU usage above 85% on {{ $labels.instance }}"
+```
+
+---
+
+## Grafana
+
+### Dashboard JSON Structure
+
+```json
+{
+  "dashboard": {
+    "title": "Service Overview",
+    "uid": "service-overview",
+    "tags": ["production", "services"],
+    "timezone": "browser",
+    "refresh": "30s",
+    "time": {
+      "from": "now-6h",
+      "to": "now"
+    },
+    "templating": {
+      "list": [
+        {
+          "name": "service",
+          "type": "query",
+          "datasource": "Prometheus",
+          "query": "label_values(up, job)",
+          "refresh": 2,
+          "multi": true,
+          "includeAll": true
+        },
+        {
+          "name": "interval",
+          "type": "interval",
+          "options": [
+            {"text": "1m", "value": "1m"},
+            {"text": "5m", "value": "5m"},
+            {"text": "15m", "value": "15m"}
+          ],
+          "current": {"text": "5m", "value": "5m"}
+        }
+      ]
+    },
+    "panels": []
+  }
+}
+```
+
+### Panel Types
+
+#### Time Series Panel
+
+```json
+{
+  "type": "timeseries",
+  "title": "Request Rate",
+  "gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
+  "targets": [
+    {
+      "expr": "sum by (status_code) (rate(http_requests_total{job=~\"$service\"}[$interval]))",
+      "legendFormat": "{{status_code}}"
+    }
+  ],
+  "fieldConfig": {
+    "defaults": {
+      "unit": "reqps",
+      "custom": {
+        "drawStyle": "line",
+        "fillOpacity": 10,
+        "stacking": {"mode": "none"}
+      }
+    }
+  }
+}
+```
+
+#### Stat Panel
+
+```json
+{
+  "type": "stat",
+  "title": "Current Error Rate",
+  "gridPos": {"h": 4, "w": 6, "x": 0, "y": 0},
+  "targets": [
+    {
+      "expr": "sum(rate(http_requests_total{job=~\"$service\",status_code=~\"5..\"}[5m])) / sum(rate(http_requests_total{job=~\"$service\"}[5m]))",
+      "instant": true
+    }
+  ],
+  "fieldConfig": {
+    "defaults": {
+      "unit": "percentunit",
+      "thresholds": {
+        "steps": [
+          {"color": "green", "value": null},
+          {"color": "yellow", "value": 0.001},
+          {"color": "red", "value": 0.01}
+        ]
+      }
+    }
+  }
+}
+```
+
+#### Gauge Panel
+
+```json
+{
+  "type": "gauge",
+  "title": "CPU Usage",
+  "targets": [
+    {
+      "expr": "1 - avg(rate(node_cpu_seconds_total{mode=\"idle\",instance=~\"$instance\"}[5m]))",
+      "instant": true
+    }
+  ],
+  "fieldConfig": {
+    "defaults": {
+      "unit": "percentunit",
+      "min": 0,
+      "max": 1,
+      "thresholds": {
+        "steps": [
+          {"color": "green", "value": null},
+          {"color": "yellow", "value": 0.7},
+          {"color": "red", "value": 0.9}
+        ]
+      }
+    }
+  }
+}
+```
+
+#### Table Panel
+
+```json
+{
+  "type": "table",
+  "title": "Top Endpoints by Error Rate",
+  "targets": [
+    {
+      "expr": "topk(10, sum by (method, path) (rate(http_requests_total{status_code=~\"5..\"}[5m])))",
+      "instant": true,
+      "format": "table"
+    }
+  ],
+  "transformations": [
+    {"id": "organize", "options": {"excludeByName": {"Time": true}}}
+  ]
+}
+```
+
+### Grafana Variables
+
+| Type | Use Case | Example |
+|------|----------|---------|
+| **Query** | Dynamic from datasource | `label_values(up, job)` |
+| **Custom** | Fixed list of values | `production,staging,development` |
+| **Interval** | Time range intervals | `1m,5m,15m,1h` |
+| **Datasource** | Multiple Prometheus instances | Type: datasource, Query: Prometheus |
+| **Text box** | Free-form input | Filter by custom string |
+
+### Annotations
+
+```json
+{
+  "annotations": {
+    "list": [
+      {
+        "name": "Deployments",
+        "datasource": "Prometheus",
+        "enable": true,
+        "expr": "changes(process_start_time_seconds{job=\"api-server\"}[1m]) > 0",
+        "tagKeys": "job",
+        "titleFormat": "Deployment: {{job}}"
+      },
+      {
+        "name": "Alerts",
+        "datasource": "-- Grafana --",
+        "enable": true,
+        "type": "alert"
+      }
+    ]
+  }
+}
+```
+
+---
+
+## OpenTelemetry Metrics
+
+### Go SDK Setup
+
+```go
+package main
+
+import (
+    "context"
+    "log"
+    "time"
+
+    "go.opentelemetry.io/otel"
+    "go.opentelemetry.io/otel/exporters/prometheus"
+    "go.opentelemetry.io/otel/metric"
+    sdkmetric "go.opentelemetry.io/otel/sdk/metric"
+)
+
+func initMeterProvider() (*sdkmetric.MeterProvider, error) {
+    exporter, err := prometheus.New()
+    if err != nil {
+        return nil, err
+    }
+
+    mp := sdkmetric.NewMeterProvider(
+        sdkmetric.WithReader(exporter),
+    )
+    otel.SetMeterProvider(mp)
+    return mp, nil
+}
+
+func main() {
+    mp, err := initMeterProvider()
+    if err != nil {
+        log.Fatal(err)
+    }
+    defer mp.Shutdown(context.Background())
+
+    meter := otel.Meter("myapp")
+
+    // Counter
+    requestCounter, _ := meter.Int64Counter(
+        "http.server.request.total",
+        metric.WithDescription("Total HTTP requests"),
+        metric.WithUnit("{request}"),
+    )
+
+    // Histogram
+    latencyHistogram, _ := meter.Float64Histogram(
+        "http.server.request.duration",
+        metric.WithDescription("HTTP request latency"),
+        metric.WithUnit("s"),
+        metric.WithExplicitBucketBoundaries(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10),
+    )
+
+    // UpDownCounter (gauge-like)
+    activeConnections, _ := meter.Int64UpDownCounter(
+        "http.server.active_connections",
+        metric.WithDescription("Active HTTP connections"),
+    )
+
+    // Usage
+    ctx := context.Background()
+    requestCounter.Add(ctx, 1, metric.WithAttributes(
+        attribute.String("method", "GET"),
+        attribute.String("path", "/api/users"),
+        attribute.Int("status_code", 200),
+    ))
+
+    start := time.Now()
+    // ... handle request ...
+    latencyHistogram.Record(ctx, time.Since(start).Seconds())
+
+    activeConnections.Add(ctx, 1)   // connection opened
+    activeConnections.Add(ctx, -1)  // connection closed
+}
+```
+
+### Python SDK Setup
+
+```python
+from opentelemetry import metrics
+from opentelemetry.sdk.metrics import MeterProvider
+from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
+from opentelemetry.exporter.prometheus import PrometheusMetricReader
+from prometheus_client import start_http_server
+
+# Prometheus exporter
+reader = PrometheusMetricReader()
+provider = MeterProvider(metric_readers=[reader])
+metrics.set_meter_provider(provider)
+
+# Start Prometheus HTTP server on port 8000
+start_http_server(8000)
+
+meter = metrics.get_meter("myapp")
+
+# Counter
+request_counter = meter.create_counter(
+    name="http.server.request.total",
+    description="Total HTTP requests",
+    unit="{request}",
+)
+
+# Histogram
+latency_histogram = meter.create_histogram(
+    name="http.server.request.duration",
+    description="HTTP request latency",
+    unit="s",
+)
+
+# UpDownCounter
+active_connections = meter.create_up_down_counter(
+    name="http.server.active_connections",
+    description="Active HTTP connections",
+)
+
+# Usage
+request_counter.add(1, {"method": "GET", "path": "/api/users", "status_code": 200})
+latency_histogram.record(0.045, {"method": "GET", "path": "/api/users"})
+active_connections.add(1)
+```
+
+### Node.js SDK Setup
+
+```javascript
+const { MeterProvider } = require('@opentelemetry/sdk-metrics');
+const { PrometheusExporter } = require('@opentelemetry/exporter-prometheus');
+const { metrics } = require('@opentelemetry/api');
+
+const exporter = new PrometheusExporter({ port: 9464 });
+const meterProvider = new MeterProvider({
+  readers: [exporter],
+});
+metrics.setGlobalMeterProvider(meterProvider);
+
+const meter = metrics.getMeter('myapp');
+
+// Counter
+const requestCounter = meter.createCounter('http.server.request.total', {
+  description: 'Total HTTP requests',
+  unit: '{request}',
+});
+
+// Histogram
+const latencyHistogram = meter.createHistogram('http.server.request.duration', {
+  description: 'HTTP request latency',
+  unit: 's',
+  advice: {
+    explicitBucketBoundaries: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
+  },
+});
+
+// UpDownCounter
+const activeConnections = meter.createUpDownCounter('http.server.active_connections', {
+  description: 'Active HTTP connections',
+});
+
+// Usage
+requestCounter.add(1, { method: 'GET', path: '/api/users', status_code: 200 });
+latencyHistogram.record(0.045, { method: 'GET', path: '/api/users' });
+activeConnections.add(1);
+```
+
+---
+
+## StatsD
+
+### Protocol Format
+
+```
+<metric_name>:<value>|<type>|@<sample_rate>|#<tags>
+```
+
+| Type | Code | Example |
+|------|------|---------|
+| Counter | `c` | `page.views:1\|c` |
+| Gauge | `g` | `fuel.level:0.5\|g` |
+| Timer | `ms` | `request.duration:320\|ms` |
+| Set | `s` | `users.uniques:user123\|s` |
+| Histogram | `h` | `request.size:512\|h` (DogStatsD) |
+| Distribution | `d` | `request.duration:320\|d` (DogStatsD) |
+
+### DogStatsD Extensions (Datadog)
+
+```
+# Counter with tags
+http.requests:1|c|#method:GET,path:/api/users,status:200
+
+# Histogram with sample rate
+http.request.duration:45.2|h|@0.5|#service:api
+
+# Gauge
+system.cpu.usage:72.5|g|#host:web01
+
+# Service check
+_sc|myservice.health|0|#env:production|m:Service is healthy
+```
+
+**When to use StatsD over Prometheus:**
+- Existing StatsD infrastructure
+- Simple counter/gauge/timer needs without complex queries
+- Push model required (ephemeral jobs, serverless)
+- Language/framework has StatsD client but no Prometheus client
+
+---
+
+## Custom Metrics Design
+
+### Naming Conventions
+
+Follow OpenMetrics/Prometheus naming:
+
+```
+<namespace>_<subsystem>_<name>_<unit>_<suffix>
+```
+
+| Component | Rules | Examples |
+|-----------|-------|---------|
+| Namespace | Application or domain | `myapp`, `payment`, `auth` |
+| Subsystem | Component within app | `http`, `db`, `cache`, `queue` |
+| Name | What is measured | `request`, `connection`, `query` |
+| Unit | SI unit (base, not milli/micro) | `seconds`, `bytes`, `ratio` |
+| Suffix | Metric type | `_total` (counter), `_info` (metadata), `_bucket` (histogram) |
+
+**Good names:**
+```
+http_server_request_duration_seconds          # histogram
+http_server_requests_total                    # counter
+db_connection_pool_active_connections         # gauge
+cache_hit_ratio                               # gauge (0-1)
+queue_messages_total                          # counter
+payment_processing_duration_seconds           # histogram
+```
+
+**Bad names:**
+```
+requestCount          # No namespace, no suffix, camelCase
+latency_ms            # Milliseconds (use seconds), no namespace
+errors                # Vague, no namespace, no suffix
+HttpRequests          # PascalCase
+```
+
+### Label Best Practices
+
+**Do:**
+- Use labels for dimensions you will filter/aggregate by
+- Keep label cardinality bounded (< 100 unique values per label)
+- Use consistent label names across metrics (`method`, not `http_method` in some and `request_method` in others)
+
+**Don't:**
+- Use user IDs, email addresses, or request IDs as labels (unbounded cardinality)
+- Use full URL paths as labels (use route templates: `/api/users/{id}`, not `/api/users/12345`)
+- Use error messages as labels (unbounded text)
+- Create more than 5-7 labels per metric
+
+### Avoiding Cardinality Bombs
+
+```
+# BAD: unbounded path label
+http_requests_total{path="/api/users/12345"}    # Millions of unique series
+http_requests_total{path="/api/users/67890"}
+
+# GOOD: use route template
+http_requests_total{route="/api/users/{id}"}    # One series per route
+
+# BAD: error message as label
+errors_total{message="connection refused to 10.0.0.5:5432"}
+
+# GOOD: error category as label
+errors_total{type="connection_refused", target="postgres"}
+```
+
+**Cardinality check query:**
+```promql
+# Find high-cardinality metrics
+topk(10, count by (__name__) ({__name__=~".+"}))
+
+# Check specific metric cardinality
+count(http_requests_total)
+```
+
+---
+
+## SLI / SLO / SLA
+
+### Definitions
+
+| Term | Definition | Example |
+|------|------------|---------|
+| **SLI** (Service Level Indicator) | Quantitative measure of service behavior | 99.2% of requests complete in < 500ms |
+| **SLO** (Service Level Objective) | Target value for an SLI | 99.5% of requests should complete in < 500ms |
+| **SLA** (Service Level Agreement) | Business contract with consequences | 99.9% availability or credit issued |
+
+**Relationship:** SLI measures reality → SLO sets the target → SLA defines business consequences.
+
+### Error Budget Calculation
+
+```
+Error budget = 1 - SLO target
+
+Example:
+  SLO = 99.9% availability
+  Error budget = 0.1% = 43.2 minutes/month
+
+  In a 30-day month:
+  - Total minutes: 43,200
+  - Allowed downtime: 43.2 minutes
+  - Allowed error requests: 0.1% of total
+```
+
+### Burn Rate Alerting
+
+Burn rate = rate at which error budget is being consumed relative to the budget period.
+
+```
+burn_rate = error_rate / (1 - SLO_target)
+```
+
+| Burn Rate | Budget Exhaustion | Alert? |
+|-----------|-------------------|--------|
+| 1x | 30 days (full period) | No |
+| 2x | 15 days | No |
+| 6x | 5 days | Ticket (warning) |
+| 14.4x | 2 days | Page (critical) |
+| 36x | 20 hours | Page immediately |
+
+**Multi-window burn rate alert (recommended):**
+
+```yaml
+# Fast burn: 14.4x burn rate over 1-hour window, confirmed by 5-minute window
+- alert: SLOHighBurnRate
+  expr: |
+    (
+      sum(rate(http_requests_total{status_code=~"5.."}[1h]))
+      /
+      sum(rate(http_requests_total[1h]))
+    ) > (14.4 * 0.001)
+    and
+    (
+      sum(rate(http_requests_total{status_code=~"5.."}[5m]))
+      /
+      sum(rate(http_requests_total[5m]))
+    ) > (14.4 * 0.001)
+  labels:
+    severity: critical
+  annotations:
+    summary: "High error budget burn rate"
+
+# Slow burn: 6x burn rate over 6-hour window, confirmed by 30-minute window
+- alert: SLOSlowBurnRate
+  expr: |
+    (
+      sum(rate(http_requests_total{status_code=~"5.."}[6h]))
+      /
+      sum(rate(http_requests_total[6h]))
+    ) > (6 * 0.001)
+    and
+    (
+      sum(rate(http_requests_total{status_code=~"5.."}[30m]))
+      /
+      sum(rate(http_requests_total[30m]))
+    ) > (6 * 0.001)
+  labels:
+    severity: warning
+```
+
+### SLO Document Template
+
+```markdown
+# SLO: [Service Name] - [SLO Name]
+
+## Overview
+- **Service:** payment-api
+- **Owner:** payments-team
+- **Last reviewed:** 2026-03-01
+
+## SLI Definition
+- **Type:** Availability (success rate)
+- **Good events:** HTTP responses with status < 500
+- **Total events:** All HTTP responses
+- **Measurement:** `sum(rate(http_requests_total{status<500}[5m])) / sum(rate(http_requests_total[5m]))`
+
+## SLO Target
+- **Target:** 99.9%
+- **Window:** 30 days (rolling)
+- **Error budget:** 0.1% = ~43 minutes of downtime
+
+## Alerting
+- **Fast burn (page):** 14.4x burn rate for 1 hour
+- **Slow burn (ticket):** 6x burn rate for 6 hours
+
+## Consequences of Missing SLO
+- Freeze non-critical deployments
+- Allocate sprint capacity to reliability
+- Review in next SLO review meeting
+```
+
+---
+
+## Alert Routing
+
+### Alertmanager Configuration
+
+```yaml
+# alertmanager.yml
+global:
+  resolve_timeout: 5m
+  slack_api_url: "https://hooks.slack.com/services/T00/B00/XXX"
+  pagerduty_url: "https://events.pagerduty.com/v2/enqueue"
+
+route:
+  receiver: "default-slack"
+  group_by: ["alertname", "job"]
+  group_wait: 30s        # Wait before sending first notification
+  group_interval: 5m     # Wait before sending updates
+  repeat_interval: 4h    # Resend if not resolved
+
+  routes:
+    # Critical alerts → PagerDuty
+    - match:
+        severity: critical
+      receiver: "pagerduty-critical"
+      group_wait: 10s
+      repeat_interval: 1h
+
+    # Warning alerts → Slack
+    - match:
+        severity: warning
+      receiver: "slack-warnings"
+      repeat_interval: 4h
+
+    # Info alerts → Slack info channel
+    - match:
+        severity: info
+      receiver: "slack-info"
+      repeat_interval: 24h
+
+    # Team-specific routing
+    - match:
+        team: database
+      receiver: "pagerduty-database"
+      routes:
+        - match:
+            severity: critical
+          receiver: "pagerduty-database"
+        - match:
+            severity: warning
+          receiver: "slack-database"
+
+receivers:
+  - name: "default-slack"
+    slack_configs:
+      - channel: "#alerts"
+        title: '{{ .GroupLabels.alertname }}'
+        text: '{{ range .Alerts }}{{ .Annotations.description }}{{ end }}'
+
+  - name: "pagerduty-critical"
+    pagerduty_configs:
+      - service_key: "<integration-key>"
+        severity: critical
+        description: '{{ .GroupLabels.alertname }}: {{ .CommonAnnotations.summary }}'
+        details:
+          description: '{{ .CommonAnnotations.description }}'
+          runbook: '{{ .CommonAnnotations.runbook_url }}'
+
+  - name: "slack-warnings"
+    slack_configs:
+      - channel: "#alerts-warning"
+        title: ':warning: {{ .GroupLabels.alertname }}'
+        text: '{{ .CommonAnnotations.description }}'
+
+  - name: "slack-info"
+    slack_configs:
+      - channel: "#alerts-info"
+
+inhibit_rules:
+  # Suppress warning if critical is already firing
+  - source_match:
+      severity: critical
+    target_match:
+      severity: warning
+    equal: ["alertname", "job"]
+```
+
+### Runbook Template
+
+```markdown
+# Runbook: [Alert Name]
+
+## Alert Details
+- **Alert:** HighErrorRate
+- **Severity:** Warning / Critical
+- **Team:** backend
+
+## Symptom
+What the user/system is experiencing when this alert fires.
+
+## Investigation Steps
+1. Check the Grafana dashboard: [link]
+2. Check recent deployments: `kubectl rollout history deployment/api`
+3. Check error logs: `kubectl logs -l app=api --tail=100 | jq 'select(.level=="ERROR")'`
+4. Check downstream dependencies: [dashboard link]
+
+## Mitigation
+Immediate actions to reduce impact:
+1. If caused by recent deploy: `kubectl rollout undo deployment/api`
+2. If caused by downstream: Enable circuit breaker / failover
+3. If caused by traffic spike: Scale horizontally
+
+## Resolution
+Steps to fully resolve:
+1. Identify root cause from logs/traces
+2. Create fix PR
+3. Deploy fix through normal pipeline
+4. Verify error rate returns to baseline
+
+## Escalation
+- Level 1: On-call engineer (this runbook)
+- Level 2: Team lead (@team-lead)
+- Level 3: VP Engineering (for customer-impacting incidents)
+```
+
+---
+
+## Uptime Monitoring
+
+### Uptime Kuma (Self-hosted)
+
+```yaml
+# docker-compose.yml
+services:
+  uptime-kuma:
+    image: louislam/uptime-kuma:1
+    restart: unless-stopped
+    ports:
+      - "3001:3001"
+    volumes:
+      - uptime-kuma-data:/app/data
+
+volumes:
+  uptime-kuma-data:
+```
+
+**Features:**
+- HTTP(s), TCP, DNS, Docker, gRPC, MQTT monitors
+- Status pages (public-facing)
+- Notifications: Slack, Discord, Telegram, PagerDuty, email, webhooks
+- Certificate expiry monitoring
+- Multi-language support
+
+### Synthetic Monitoring
+
+Run scripted checks from multiple regions to verify end-to-end functionality:
+
+```javascript
+// Example: Grafana synthetic monitoring check
+import { check } from 'k6';
+import http from 'k6/http';
+
+export default function () {
+  const res = http.get('https://api.example.com/health');
+  check(res, {
+    'status is 200': (r) => r.status === 200,
+    'response time < 500ms': (r) => r.timings.duration < 500,
+    'body contains ok': (r) => r.body.includes('"status":"ok"'),
+  });
+}
+```
+
+### Status Pages
+
+Communicate service health to users:
+
+| Tool | Type | Features |
+|------|------|----------|
+| **Uptime Kuma** | Self-hosted | Free, built-in status page |
+| **Betteruptime** | SaaS | Incident management + status page |
+| **Cachet** | Self-hosted | PHP-based, mature |
+| **Instatus** | SaaS | Modern, integrations |
+| **Statuspage (Atlassian)** | SaaS | Enterprise, expensive |
+
+**Status page best practices:**
+- Show individual component status (API, database, CDN, auth)
+- Include historical uptime percentage (30/90 day)
+- Post incident updates promptly (investigating → identified → monitoring → resolved)
+- Subscribe option for email/SMS/RSS notifications

+ 996 - 0
skills/monitoring-ops/references/tracing.md

@@ -0,0 +1,996 @@
+# Distributed Tracing Reference
+
+Comprehensive reference for OpenTelemetry, context propagation, sampling, and instrumentation patterns.
+
+---
+
+## OpenTelemetry Architecture
+
+```
+┌──────────────┐     ┌──────────────┐     ┌──────────────┐
+│  Application │     │  Application │     │  Application │
+│  (SDK + API) │     │  (SDK + API) │     │  (SDK + API) │
+└──────┬───────┘     └──────┬───────┘     └──────┬───────┘
+       │ OTLP              │ OTLP              │ OTLP
+       ▼                   ▼                   ▼
+┌─────────────────────────────────────────────────────────┐
+│                   OTel Collector                         │
+│  ┌───────────┐  ┌────────────┐  ┌───────────────────┐  │
+│  │ Receivers │→ │ Processors │→ │    Exporters      │  │
+│  │ (OTLP,    │  │ (batch,    │  │ (Jaeger, Tempo,   │  │
+│  │  Jaeger,  │  │  filter,   │  │  Datadog, OTLP)   │  │
+│  │  Zipkin)  │  │  tail      │  │                   │  │
+│  │           │  │  sampling) │  │                   │  │
+│  └───────────┘  └────────────┘  └───────────────────┘  │
+└─────────────────────────────────────────────────────────┘
+       │                    │                    │
+       ▼                    ▼                    ▼
+┌──────────┐        ┌──────────┐        ┌──────────────┐
+│  Jaeger  │        │  Tempo   │        │   Datadog    │
+│  (UI)    │        │  (store) │        │   (SaaS)     │
+└──────────┘        └──────────┘        └──────────────┘
+```
+
+### Components
+
+| Component | Role | Notes |
+|-----------|------|-------|
+| **API** | Stable interfaces for instrumentation | Language-specific, vendor-neutral |
+| **SDK** | Implementation of the API | Configures sampling, export, processing |
+| **Collector** | Receives, processes, exports telemetry | Deploy as sidecar or gateway |
+| **Exporters** | Send data to backends | OTLP (preferred), Jaeger, Zipkin, vendor-specific |
+| **Auto-instrumentation** | Automatic span creation for frameworks | HTTP, gRPC, database, messaging |
+
+### Collector Configuration
+
+```yaml
+# otel-collector-config.yml
+receivers:
+  otlp:
+    protocols:
+      grpc:
+        endpoint: 0.0.0.0:4317
+      http:
+        endpoint: 0.0.0.0:4318
+
+processors:
+  batch:
+    timeout: 5s
+    send_batch_size: 8192
+    send_batch_max_size: 16384
+
+  memory_limiter:
+    check_interval: 1s
+    limit_mib: 1024
+    spike_limit_mib: 256
+
+  # Tail-based sampling (decide after seeing complete trace)
+  tail_sampling:
+    decision_wait: 10s
+    num_traces: 100000
+    policies:
+      # Always sample errors
+      - name: errors
+        type: status_code
+        status_code:
+          status_codes: [ERROR]
+      # Always sample slow traces (> 2s)
+      - name: slow-traces
+        type: latency
+        latency:
+          threshold_ms: 2000
+      # Sample 10% of everything else
+      - name: probabilistic
+        type: probabilistic
+        probabilistic:
+          sampling_percentage: 10
+
+  # Add resource attributes
+  resource:
+    attributes:
+      - key: environment
+        value: production
+        action: upsert
+
+exporters:
+  otlp/jaeger:
+    endpoint: jaeger:4317
+    tls:
+      insecure: true
+
+  otlp/tempo:
+    endpoint: tempo:4317
+    tls:
+      insecure: true
+
+  debug:
+    verbosity: detailed
+
+service:
+  pipelines:
+    traces:
+      receivers: [otlp]
+      processors: [memory_limiter, tail_sampling, batch, resource]
+      exporters: [otlp/jaeger]
+    metrics:
+      receivers: [otlp]
+      processors: [memory_limiter, batch]
+      exporters: [otlp/tempo]
+    logs:
+      receivers: [otlp]
+      processors: [memory_limiter, batch]
+      exporters: [debug]
+```
+
+---
+
+## Span Model
+
+### Span Anatomy
+
+```
+Trace ID: 4bf92f3577b34da6a3ce929d0e0e4736
+│
+├─ Span: "GET /api/orders"
+│  ├─ Span ID: 00f067aa0ba902b7
+│  ├─ Parent: (none - root span)
+│  ├─ Start: 2026-03-09T14:32:01.000Z
+│  ├─ End:   2026-03-09T14:32:01.245Z
+│  ├─ Status: OK
+│  ├─ Attributes:
+│  │   http.method: GET
+│  │   http.url: /api/orders?user_id=789
+│  │   http.status_code: 200
+│  │   http.response_content_length: 4523
+│  ├─ Events:
+│  │   └─ "cache.miss" at T+5ms {key: "orders:usr-789"}
+│  │
+│  ├─ Span: "SELECT orders"
+│  │  ├─ Span ID: a1b2c3d4e5f60718
+│  │  ├─ Parent: 00f067aa0ba902b7
+│  │  ├─ Duration: 45ms
+│  │  ├─ Attributes:
+│  │  │   db.system: postgresql
+│  │  │   db.operation: SELECT
+│  │  │   db.statement: SELECT * FROM orders WHERE user_id = $1
+│  │  │   db.rows_affected: 12
+│  │  └─ Status: OK
+│  │
+│  └─ Span: "GET payment-service/status"
+│     ├─ Span ID: b2c3d4e5f6071829
+│     ├─ Parent: 00f067aa0ba902b7
+│     ├─ Duration: 120ms
+│     ├─ Attributes:
+│     │   http.method: GET
+│     │   http.url: http://payment-service:8080/status
+│     │   http.status_code: 200
+│     │   peer.service: payment-service
+│     └─ Status: OK
+```
+
+### Span Attributes (Semantic Conventions)
+
+#### HTTP Spans
+
+| Attribute | Example | Notes |
+|-----------|---------|-------|
+| `http.request.method` | `GET` | HTTP method |
+| `url.path` | `/api/orders` | URL path |
+| `http.response.status_code` | `200` | Response status |
+| `http.request.body.size` | `1024` | Request body bytes |
+| `http.response.body.size` | `4523` | Response body bytes |
+| `server.address` | `api.example.com` | Server hostname |
+| `server.port` | `443` | Server port |
+| `network.protocol.version` | `1.1` | HTTP version |
+| `user_agent.original` | `Mozilla/5.0...` | User agent string |
+
+#### Database Spans
+
+| Attribute | Example | Notes |
+|-----------|---------|-------|
+| `db.system` | `postgresql` | Database type |
+| `db.namespace` | `myapp` | Database name |
+| `db.operation.name` | `SELECT` | SQL operation |
+| `db.query.text` | `SELECT * FROM...` | Sanitized query |
+| `server.address` | `db.example.com` | DB host |
+| `server.port` | `5432` | DB port |
+| `db.response.rows_affected` | `12` | Rows returned/affected |
+
+#### gRPC Spans
+
+| Attribute | Example | Notes |
+|-----------|---------|-------|
+| `rpc.system` | `grpc` | RPC system |
+| `rpc.service` | `myapp.UserService` | Service name |
+| `rpc.method` | `GetUser` | Method name |
+| `rpc.grpc.status_code` | `0` | gRPC status code |
+
+### Span Status
+
+| Status | When | Notes |
+|--------|------|-------|
+| `UNSET` | Default | Operation completed, no explicit status |
+| `OK` | Explicitly successful | Use sparingly, UNSET is fine for success |
+| `ERROR` | Operation failed | Always set for 5xx responses, exceptions |
+
+---
+
+## SDK Setup
+
+### Go
+
+```go
+package main
+
+import (
+    "context"
+    "log"
+
+    "go.opentelemetry.io/otel"
+    "go.opentelemetry.io/otel/attribute"
+    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
+    "go.opentelemetry.io/otel/propagation"
+    "go.opentelemetry.io/otel/sdk/resource"
+    sdktrace "go.opentelemetry.io/otel/sdk/trace"
+    semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
+    "go.opentelemetry.io/otel/trace"
+)
+
+func initTracer(ctx context.Context) (*sdktrace.TracerProvider, error) {
+    // OTLP gRPC exporter (sends to Collector)
+    exporter, err := otlptracegrpc.New(ctx,
+        otlptracegrpc.WithEndpoint("otel-collector:4317"),
+        otlptracegrpc.WithInsecure(),
+    )
+    if err != nil {
+        return nil, err
+    }
+
+    // Resource: describes this service
+    res, err := resource.Merge(
+        resource.Default(),
+        resource.NewWithAttributes(
+            semconv.SchemaURL,
+            semconv.ServiceName("order-service"),
+            semconv.ServiceVersion("1.4.2"),
+            attribute.String("environment", "production"),
+        ),
+    )
+    if err != nil {
+        return nil, err
+    }
+
+    // TracerProvider with batch span processor
+    tp := sdktrace.NewTracerProvider(
+        sdktrace.WithBatcher(exporter),
+        sdktrace.WithResource(res),
+        sdktrace.WithSampler(sdktrace.ParentBased(
+            sdktrace.TraceIDRatioBased(0.1), // 10% head sampling
+        )),
+    )
+
+    // Set global TracerProvider and propagator
+    otel.SetTracerProvider(tp)
+    otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
+        propagation.TraceContext{},  // W3C TraceContext
+        propagation.Baggage{},       // W3C Baggage
+    ))
+
+    return tp, nil
+}
+
+func main() {
+    ctx := context.Background()
+    tp, err := initTracer(ctx)
+    if err != nil {
+        log.Fatal(err)
+    }
+    defer tp.Shutdown(ctx)
+
+    // Create spans
+    tracer := otel.Tracer("order-service")
+
+    ctx, span := tracer.Start(ctx, "ProcessOrder",
+        trace.WithAttributes(
+            attribute.String("order.id", "ord-123"),
+            attribute.Int("order.items", 3),
+        ),
+    )
+    defer span.End()
+
+    // Add events
+    span.AddEvent("order.validated", trace.WithAttributes(
+        attribute.Bool("has_discount", true),
+    ))
+
+    // Record errors
+    if err := processPayment(ctx); err != nil {
+        span.RecordError(err)
+        span.SetStatus(codes.Error, err.Error())
+    }
+}
+```
+
+### Go Auto-instrumentation
+
+```go
+import (
+    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
+    "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
+    "go.opentelemetry.io/contrib/instrumentation/github.com/jackc/pgx/v5/otelpgx"
+)
+
+// HTTP server: wrap handler
+mux := http.NewServeMux()
+mux.HandleFunc("/api/orders", handleOrders)
+handler := otelhttp.NewHandler(mux, "server")
+http.ListenAndServe(":8080", handler)
+
+// HTTP client: wrap transport
+client := &http.Client{
+    Transport: otelhttp.NewTransport(http.DefaultTransport),
+}
+
+// gRPC server: add interceptors
+server := grpc.NewServer(
+    grpc.UnaryInterceptor(otelgrpc.UnaryServerInterceptor()),
+    grpc.StreamInterceptor(otelgrpc.StreamServerInterceptor()),
+)
+
+// gRPC client: add interceptors
+conn, _ := grpc.Dial(addr,
+    grpc.WithUnaryInterceptor(otelgrpc.UnaryClientInterceptor()),
+    grpc.WithStreamInterceptor(otelgrpc.StreamClientInterceptor()),
+)
+
+// pgx (PostgreSQL): add tracer
+config, _ := pgxpool.ParseConfig(databaseURL)
+config.ConnConfig.Tracer = otelpgx.NewTracer()
+pool, _ := pgxpool.NewWithConfig(ctx, config)
+```
+
+### Python
+
+```python
+from opentelemetry import trace
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import BatchSpanProcessor
+from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
+from opentelemetry.sdk.resources import Resource, SERVICE_NAME, SERVICE_VERSION
+from opentelemetry.propagators.composite import CompositePropagator
+from opentelemetry.propagators.textmap import DefaultTextMapPropagator
+from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
+from opentelemetry.baggage.propagation import W3CBaggagePropagator
+
+# Resource
+resource = Resource.create({
+    SERVICE_NAME: "order-service",
+    SERVICE_VERSION: "1.4.2",
+    "environment": "production",
+})
+
+# TracerProvider
+provider = TracerProvider(resource=resource)
+provider.add_span_processor(
+    BatchSpanProcessor(
+        OTLPSpanExporter(endpoint="otel-collector:4317", insecure=True)
+    )
+)
+trace.set_tracer_provider(provider)
+
+# Propagator
+from opentelemetry import propagate
+propagate.set_global_textmap(CompositePropagator([
+    TraceContextTextMapPropagator(),
+    W3CBaggagePropagator(),
+]))
+
+# Create spans
+tracer = trace.get_tracer("order-service")
+
+with tracer.start_as_current_span("process_order", attributes={
+    "order.id": "ord-123",
+    "order.items": 3,
+}) as span:
+    span.add_event("order.validated", {"has_discount": True})
+
+    try:
+        process_payment(order)
+    except Exception as e:
+        span.record_exception(e)
+        span.set_status(trace.Status(trace.StatusCode.ERROR, str(e)))
+        raise
+```
+
+### Python Auto-instrumentation
+
+```bash
+# Install auto-instrumentation packages
+pip install opentelemetry-distro opentelemetry-exporter-otlp
+opentelemetry-bootstrap -a install  # Installs all detected instrumentors
+
+# Run with auto-instrumentation
+opentelemetry-instrument \
+    --service_name order-service \
+    --exporter_otlp_endpoint http://otel-collector:4317 \
+    python app.py
+```
+
+```python
+# Or configure programmatically
+from opentelemetry.instrumentation.flask import FlaskInstrumentor
+from opentelemetry.instrumentation.requests import RequestsInstrumentor
+from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor
+from opentelemetry.instrumentation.redis import RedisInstrumentor
+
+FlaskInstrumentor().instrument_app(app)
+RequestsInstrumentor().instrument()
+Psycopg2Instrumentor().instrument()
+RedisInstrumentor().instrument()
+```
+
+### Node.js
+
+```javascript
+// tracing.js - import BEFORE other modules
+const { NodeSDK } = require('@opentelemetry/sdk-node');
+const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-grpc');
+const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
+const { Resource } = require('@opentelemetry/resources');
+const { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } = require('@opentelemetry/semantic-conventions');
+
+const sdk = new NodeSDK({
+  resource: new Resource({
+    [ATTR_SERVICE_NAME]: 'order-service',
+    [ATTR_SERVICE_VERSION]: '1.4.2',
+    environment: 'production',
+  }),
+  traceExporter: new OTLPTraceExporter({
+    url: 'http://otel-collector:4317',
+  }),
+  instrumentations: [
+    getNodeAutoInstrumentations({
+      // Disable fs instrumentation (too noisy)
+      '@opentelemetry/instrumentation-fs': { enabled: false },
+    }),
+  ],
+});
+
+sdk.start();
+
+// Graceful shutdown
+process.on('SIGTERM', () => {
+  sdk.shutdown().then(() => process.exit(0));
+});
+```
+
+```javascript
+// Manual span creation
+const { trace } = require('@opentelemetry/api');
+
+const tracer = trace.getTracer('order-service');
+
+async function processOrder(orderId) {
+  return tracer.startActiveSpan('process_order', {
+    attributes: { 'order.id': orderId },
+  }, async (span) => {
+    try {
+      span.addEvent('order.validated');
+      await processPayment(orderId);
+      span.setStatus({ code: SpanStatusCode.OK });
+    } catch (err) {
+      span.recordException(err);
+      span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
+      throw err;
+    } finally {
+      span.end();
+    }
+  });
+}
+```
+
+---
+
+## Context Propagation
+
+### W3C TraceContext
+
+The standard for propagating trace context across service boundaries.
+
+**Request headers:**
+```
+traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
+tracestate: vendor1=value1,vendor2=value2
+```
+
+**Format:** `version-trace_id-parent_id-trace_flags`
+
+| Field | Size | Description |
+|-------|------|-------------|
+| version | 2 hex | Always `00` |
+| trace_id | 32 hex | Unique trace identifier |
+| parent_id | 16 hex | Span ID of the caller |
+| trace_flags | 2 hex | `01` = sampled, `00` = not sampled |
+
+### B3 Propagation (Zipkin)
+
+Legacy format still used by some systems:
+
+```
+# Single header (compact)
+b3: 4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-1
+
+# Multi-header
+X-B3-TraceId: 4bf92f3577b34da6a3ce929d0e0e4736
+X-B3-SpanId: 00f067aa0ba902b7
+X-B3-ParentSpanId: (parent span)
+X-B3-Sampled: 1
+```
+
+### Baggage
+
+Propagate arbitrary key-value pairs across service boundaries:
+
+```
+baggage: userId=usr-789,region=us-east-1
+```
+
+```go
+// Go: set baggage
+bag, _ := baggage.Parse("userId=usr-789,region=us-east-1")
+ctx = baggage.ContextWithBaggage(ctx, bag)
+
+// Go: read baggage
+bag := baggage.FromContext(ctx)
+userId := bag.Member("userId").Value()
+```
+
+**Use sparingly:** Baggage is sent with every request. Don't put large values or sensitive data in baggage.
+
+---
+
+## Sampling Strategies
+
+### Head-based Sampling
+
+Decision made at trace creation, propagated to all downstream services.
+
+| Sampler | Description | Config |
+|---------|-------------|--------|
+| `AlwaysOn` | Sample everything | Development only |
+| `AlwaysOff` | Sample nothing | Disable tracing |
+| `TraceIDRatioBased` | Sample N% of traces | `ratio: 0.1` for 10% |
+| `ParentBased` | Follow parent's decision | Default, wrap another sampler |
+
+```go
+// Go: 10% sampling, respecting parent's decision
+sampler := sdktrace.ParentBased(
+    sdktrace.TraceIDRatioBased(0.1),
+)
+```
+
+```python
+# Python: 10% sampling
+from opentelemetry.sdk.trace.sampling import TraceIdRatioBased, ParentBasedTraceIdRatio
+sampler = ParentBasedTraceIdRatio(0.1)
+```
+
+### Tail-based Sampling
+
+Decision made after the trace completes. Requires the OTel Collector.
+
+**Advantages:**
+- Always captures error traces
+- Always captures slow traces
+- More representative sampling
+
+**Configuration (Collector):**
+
+```yaml
+processors:
+  tail_sampling:
+    decision_wait: 10s         # Wait for spans to arrive
+    num_traces: 100000         # Max traces in memory
+    expected_new_traces_per_sec: 1000
+    policies:
+      # Always keep errors
+      - name: errors-policy
+        type: status_code
+        status_code:
+          status_codes: [ERROR]
+
+      # Always keep slow traces (root span > 2s)
+      - name: latency-policy
+        type: latency
+        latency:
+          threshold_ms: 2000
+
+      # Keep all traces for specific operations
+      - name: critical-operations
+        type: string_attribute
+        string_attribute:
+          key: operation
+          values: [payment, refund, account_deletion]
+
+      # Sample 5% of remaining traces
+      - name: probabilistic-policy
+        type: probabilistic
+        probabilistic:
+          sampling_percentage: 5
+
+      # Composite: apply multiple policies with priority
+      - name: composite-policy
+        type: composite
+        composite:
+          max_total_spans_per_second: 1000
+          policy_order: [errors-policy, latency-policy, probabilistic-policy]
+          rate_allocation:
+            - policy: errors-policy
+              percent: 50
+            - policy: latency-policy
+              percent: 30
+            - policy: probabilistic-policy
+              percent: 20
+```
+
+---
+
+## Jaeger
+
+### Deployment
+
+#### All-in-One (Development)
+
+```yaml
+# docker-compose.yml
+services:
+  jaeger:
+    image: jaegertracing/all-in-one:1.54
+    ports:
+      - "16686:16686"   # UI
+      - "4317:4317"     # OTLP gRPC
+      - "4318:4318"     # OTLP HTTP
+      - "14250:14250"   # Jaeger gRPC
+    environment:
+      COLLECTOR_OTLP_ENABLED: true
+```
+
+#### Production (with Elasticsearch)
+
+```yaml
+services:
+  jaeger-collector:
+    image: jaegertracing/jaeger-collector:1.54
+    environment:
+      SPAN_STORAGE_TYPE: elasticsearch
+      ES_SERVER_URLS: http://elasticsearch:9200
+      COLLECTOR_OTLP_ENABLED: true
+    ports:
+      - "4317:4317"
+      - "14250:14250"
+
+  jaeger-query:
+    image: jaegertracing/jaeger-query:1.54
+    environment:
+      SPAN_STORAGE_TYPE: elasticsearch
+      ES_SERVER_URLS: http://elasticsearch:9200
+    ports:
+      - "16686:16686"
+
+  elasticsearch:
+    image: elasticsearch:8.12.0
+    environment:
+      discovery.type: single-node
+      xpack.security.enabled: false
+      ES_JAVA_OPTS: "-Xms512m -Xmx512m"
+    volumes:
+      - es-data:/usr/share/elasticsearch/data
+
+volumes:
+  es-data:
+```
+
+### Jaeger UI Features
+
+| Feature | Description |
+|---------|-------------|
+| **Search** | Find traces by service, operation, tags, duration, time range |
+| **Trace View** | Waterfall visualization of spans with timing |
+| **Compare** | Compare two traces side by side |
+| **Dependencies** | Service dependency graph (DAG) |
+| **Deep Dependency** | Trace-aware dependency analysis |
+| **Monitor** | RED metrics derived from traces |
+
+---
+
+## Async Trace Propagation
+
+### Go: context.Context
+
+```go
+// Context carries trace information automatically
+func processOrder(ctx context.Context, orderID string) error {
+    // Start child span — automatically linked to parent via ctx
+    ctx, span := tracer.Start(ctx, "processOrder")
+    defer span.End()
+
+    // Pass context to goroutines
+    g, ctx := errgroup.WithContext(ctx)
+    g.Go(func() error {
+        return validateOrder(ctx, orderID)  // ctx carries trace
+    })
+    g.Go(func() error {
+        return checkInventory(ctx, orderID) // ctx carries trace
+    })
+    return g.Wait()
+}
+```
+
+### Python: contextvars
+
+```python
+import asyncio
+from opentelemetry import trace, context
+
+tracer = trace.get_tracer("order-service")
+
+async def process_order(order_id: str):
+    with tracer.start_as_current_span("process_order") as span:
+        # asyncio tasks automatically inherit context
+        results = await asyncio.gather(
+            validate_order(order_id),
+            check_inventory(order_id),
+        )
+        return results
+
+async def validate_order(order_id: str):
+    # This span is automatically a child of process_order
+    with tracer.start_as_current_span("validate_order"):
+        pass
+```
+
+### Node.js: AsyncLocalStorage
+
+```javascript
+const { trace, context } = require('@opentelemetry/api');
+
+// OpenTelemetry SDK uses AsyncLocalStorage internally
+// Spans are automatically propagated through async operations
+
+async function processOrder(orderId) {
+  return tracer.startActiveSpan('process_order', async (span) => {
+    try {
+      // Promise.all preserves context
+      const [validation, inventory] = await Promise.all([
+        validateOrder(orderId),  // context propagated
+        checkInventory(orderId), // context propagated
+      ]);
+      return { validation, inventory };
+    } finally {
+      span.end();
+    }
+  });
+}
+```
+
+---
+
+## Database Query Tracing
+
+### Span Attributes for Database Queries
+
+```json
+{
+  "name": "SELECT users",
+  "attributes": {
+    "db.system": "postgresql",
+    "db.namespace": "myapp",
+    "db.operation.name": "SELECT",
+    "db.query.text": "SELECT id, name, email FROM users WHERE id = $1",
+    "server.address": "db.example.com",
+    "server.port": 5432,
+    "db.response.rows_affected": 1
+  }
+}
+```
+
+### Query Parameter Sanitization
+
+**Never log actual parameter values** — they may contain PII:
+
+```go
+// GOOD: sanitized
+span.SetAttributes(
+    attribute.String("db.query.text", "SELECT * FROM users WHERE id = $1 AND email = $2"),
+)
+
+// BAD: contains PII
+span.SetAttributes(
+    attribute.String("db.query.text", "SELECT * FROM users WHERE id = 789 AND email = 'john@example.com'"),
+)
+```
+
+### N+1 Query Detection
+
+Use traces to identify N+1 queries — they appear as many identical DB spans under one parent:
+
+```
+GET /api/orders (250ms)
+├── SELECT orders WHERE user_id = $1 (5ms)
+├── SELECT product WHERE id = $1 (3ms)    ← N+1
+├── SELECT product WHERE id = $1 (3ms)    ← N+1
+├── SELECT product WHERE id = $1 (4ms)    ← N+1
+├── SELECT product WHERE id = $1 (3ms)    ← N+1
+└── ... (20 more identical queries)
+```
+
+**Fix:** Use `SELECT * FROM products WHERE id IN ($1, $2, ...)` or JOIN.
+
+---
+
+## HTTP Client/Server Tracing
+
+### Automatic Instrumentation
+
+Most OpenTelemetry auto-instrumentation libraries create spans automatically for:
+- HTTP server requests (incoming)
+- HTTP client requests (outgoing)
+- gRPC server/client calls
+- Database queries
+- Redis operations
+- Message queue operations (Kafka, RabbitMQ)
+
+### Custom Attributes on Auto-instrumented Spans
+
+```go
+// Add business context to auto-created spans
+span := trace.SpanFromContext(ctx)
+span.SetAttributes(
+    attribute.String("user.id", userID),
+    attribute.String("order.id", orderID),
+    attribute.String("tenant.id", tenantID),
+)
+```
+
+### Error Recording
+
+```go
+// Proper error recording
+if err != nil {
+    span.RecordError(err)  // Creates an event with exception details
+    span.SetStatus(codes.Error, err.Error())  // Marks span as error
+    return err
+}
+
+// For HTTP handlers
+if statusCode >= 500 {
+    span.SetStatus(codes.Error, fmt.Sprintf("HTTP %d", statusCode))
+}
+// Note: 4xx is NOT an error from the server's perspective
+```
+
+---
+
+## gRPC Tracing
+
+### Server Interceptors
+
+```go
+import "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
+
+server := grpc.NewServer(
+    grpc.StatsHandler(otelgrpc.NewServerHandler()),
+)
+```
+
+### Client Interceptors
+
+```go
+conn, err := grpc.Dial(addr,
+    grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
+)
+```
+
+### Streaming Spans
+
+For streaming RPCs, spans cover the entire stream lifecycle:
+
+```
+ServerStream (user.ListUsers) — 2.5s
+├─ stream.message.sent (1) — T+10ms
+├─ stream.message.sent (2) — T+50ms
+├─ stream.message.sent (3) — T+120ms
+└─ stream.message.sent (4) — T+200ms
+```
+
+### Metadata Propagation
+
+Context is automatically propagated via gRPC metadata when using OTel interceptors:
+
+```go
+// Automatic: OTel interceptors inject/extract from gRPC metadata
+// metadata equivalent to HTTP headers:
+//   traceparent → grpc-metadata-traceparent
+//   tracestate  → grpc-metadata-tracestate
+```
+
+---
+
+## Trace-Based Testing
+
+### Asserting Span Structure
+
+```go
+// Go: using in-memory exporter for testing
+import (
+    sdktrace "go.opentelemetry.io/otel/sdk/trace"
+    "go.opentelemetry.io/otel/sdk/trace/tracetest"
+)
+
+func TestOrderProcessing(t *testing.T) {
+    exporter := tracetest.NewInMemoryExporter()
+    tp := sdktrace.NewTracerProvider(
+        sdktrace.WithSyncer(exporter),
+    )
+    otel.SetTracerProvider(tp)
+
+    // Run the operation
+    processOrder(context.Background(), "ord-123")
+
+    // Assert spans
+    spans := exporter.GetSpans()
+    assert.Len(t, spans, 3)
+
+    rootSpan := spans[0]
+    assert.Equal(t, "processOrder", rootSpan.Name)
+    assert.Equal(t, codes.Ok, rootSpan.Status.Code)
+
+    dbSpan := spans[1]
+    assert.Equal(t, "SELECT orders", dbSpan.Name)
+    assert.Equal(t, "postgresql", dbSpan.Attributes["db.system"])
+
+    // Verify parent-child relationship
+    assert.Equal(t, rootSpan.SpanContext.SpanID(), dbSpan.Parent.SpanID())
+}
+```
+
+```python
+# Python: using in-memory exporter
+from opentelemetry.sdk.trace.export.in_memory import InMemorySpanExporter
+
+exporter = InMemorySpanExporter()
+provider = TracerProvider()
+provider.add_span_processor(SimpleSpanProcessor(exporter))
+trace.set_tracer_provider(provider)
+
+# Run operation
+process_order("ord-123")
+
+# Assert
+spans = exporter.get_finished_spans()
+assert len(spans) == 3
+assert spans[0].name == "process_order"
+assert spans[1].name == "SELECT orders"
+assert spans[1].parent.span_id == spans[0].context.span_id
+```
+
+### Verifying Context Propagation
+
+```go
+func TestContextPropagation(t *testing.T) {
+    // Create a trace context
+    ctx, span := tracer.Start(context.Background(), "test-root")
+    traceID := span.SpanContext().TraceID()
+
+    // Call service that makes outbound HTTP call
+    handler.ServeHTTP(recorder, req.WithContext(ctx))
+
+    // Verify all spans share the same trace ID
+    spans := exporter.GetSpans()
+    for _, s := range spans {
+        assert.Equal(t, traceID, s.SpanContext.TraceID())
+    }
+
+    span.End()
+}
+```

+ 0 - 0
skills/monitoring-ops/scripts/.gitkeep


+ 469 - 0
skills/nginx-ops/SKILL.md

@@ -0,0 +1,469 @@
+---
+name: nginx-ops
+description: "Nginx configuration, reverse proxy, SSL/TLS, load balancing, and performance tuning. Use for: nginx, reverse proxy, load balancer, proxy_pass, ssl certificate, lets encrypt, web server, location block, upstream, server block, nginx config, certbot, hsts, gzip, rate limiting."
+allowed-tools: "Read Write Bash"
+related-skills: [docker-ops, security-ops, ci-cd-ops]
+---
+
+# Nginx Operations
+
+Comprehensive Nginx configuration, reverse proxy patterns, SSL/TLS hardening, load balancing strategies, and performance optimization for production deployments.
+
+---
+
+## Configuration Architecture Quick Reference
+
+```
+nginx.conf (main context)
+├── worker_processes auto;
+├── worker_rlimit_nofile 65535;
+│
+├── events {                          # Connection handling
+│   ├── worker_connections 4096;
+│   └── multi_accept on;
+│   }
+│
+├── http {                            # HTTP server settings
+│   ├── include mime.types;
+│   ├── default_type application/octet-stream;
+│   ├── sendfile on;
+│   ├── gzip on;
+│   │
+│   ├── upstream backend {            # Load balancing pool
+│   │   └── server 127.0.0.1:3000;
+│   │   }
+│   │
+│   ├── server {                      # Virtual host
+│   │   ├── listen 443 ssl;
+│   │   ├── server_name example.com;
+│   │   │
+│   │   ├── location / {              # Request routing
+│   │   │   └── proxy_pass http://backend;
+│   │   │   }
+│   │   │
+│   │   └── location /static/ {
+│   │       └── root /var/www;
+│   │       }
+│   │   }
+│   │
+│   └── include /etc/nginx/conf.d/*.conf;
+│   }
+│
+└── stream {                          # TCP/UDP proxying (optional)
+    └── server { ... }
+    }
+```
+
+### Directive Inheritance Rules
+
+| Rule | Behavior | Example |
+|------|----------|---------|
+| **Inherit down** | Child blocks inherit parent directives | `gzip on;` in `http` applies to all `server` blocks |
+| **Override** | Child directive overrides parent | `gzip off;` in `location` overrides `http`-level `gzip on;` |
+| **Array directives** | NOT inherited - must be redeclared | `proxy_set_header` in `location` replaces ALL headers from `server` |
+| **No upward** | Inner blocks never affect outer | `location`-level settings don't affect `server` |
+
+**Critical:** Array-type directives (`proxy_set_header`, `add_header`, `proxy_hide_header`) are **completely replaced** when redefined in a child block, not merged. If you set one `proxy_set_header` in a `location`, you must redeclare ALL of them.
+
+---
+
+## Reverse Proxy Decision Tree
+
+```
+Need to proxy requests?
+│
+├─ Single backend server?
+│  └─ Use simple proxy_pass
+│     proxy_pass http://127.0.0.1:3000;
+│
+├─ Multiple backend servers?
+│  │
+│  ├─ Need session persistence?
+│  │  ├─ By client IP → ip_hash
+│  │  └─ By cookie    → sticky cookie (Nginx Plus)
+│  │
+│  ├─ Backends have unequal capacity?
+│  │  └─ Use weight parameter
+│  │     server backend1:3000 weight=3;
+│  │     server backend2:3000 weight=1;
+│  │
+│  ├─ Want fewest active connections?
+│  │  └─ least_conn
+│  │
+│  ├─ Want even random distribution?
+│  │  └─ random two least_conn
+│  │
+│  └─ Default (no special needs)?
+│     └─ round-robin (default, no directive needed)
+│
+├─ WebSocket connections?
+│  └─ Add Upgrade + Connection headers
+│     proxy_set_header Upgrade $http_upgrade;
+│     proxy_set_header Connection "upgrade";
+│
+├─ gRPC backend?
+│  └─ Use grpc_pass grpc://backend;
+│
+└─ Streaming / Server-Sent Events?
+   └─ Disable buffering
+      proxy_buffering off;
+```
+
+---
+
+## SSL/TLS Quick Start
+
+### Let's Encrypt with Certbot
+
+```bash
+# Install certbot
+sudo apt install certbot python3-certbot-nginx   # Debian/Ubuntu
+sudo dnf install certbot python3-certbot-nginx    # RHEL/Fedora
+
+# Obtain certificate (nginx plugin - easiest)
+sudo certbot --nginx -d example.com -d www.example.com
+
+# Obtain certificate (webroot - no nginx restart)
+sudo certbot certonly --webroot -w /var/www/html -d example.com
+
+# Test auto-renewal
+sudo certbot renew --dry-run
+```
+
+### Minimal Production SSL Config
+
+```nginx
+server {
+    listen 443 ssl http2;
+    server_name example.com;
+
+    # Certificates
+    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
+
+    # Modern TLS (1.2 + 1.3)
+    ssl_protocols TLSv1.2 TLSv1.3;
+    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
+    ssl_prefer_server_ciphers off;
+
+    # HSTS (2 years)
+    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
+
+    # OCSP Stapling
+    ssl_stapling on;
+    ssl_stapling_verify on;
+    ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
+    resolver 1.1.1.1 8.8.8.8 valid=300s;
+    resolver_timeout 5s;
+
+    # Session caching
+    ssl_session_cache shared:SSL:10m;
+    ssl_session_timeout 1d;
+    ssl_session_tickets off;
+
+    root /var/www/example.com;
+    index index.html;
+}
+
+# HTTP → HTTPS redirect
+server {
+    listen 80;
+    server_name example.com www.example.com;
+    return 301 https://example.com$request_uri;
+}
+```
+
+---
+
+## Location Matching Order
+
+Nginx evaluates `location` blocks in a specific priority order, **not** in the order they appear in the config file.
+
+| Priority | Modifier | Type | Example | Behavior |
+|----------|----------|------|---------|----------|
+| 1 | `=` | Exact match | `location = /favicon.ico` | Stops search immediately on match |
+| 2 | `^~` | Prefix (no regex) | `location ^~ /static/` | Stops search if this prefix matches (skips regex) |
+| 3 | `~` | Regex (case-sensitive) | `location ~ \.php$` | First matching regex wins |
+| 3 | `~*` | Regex (case-insensitive) | `location ~* \.(jpg\|png)$` | First matching regex wins |
+| 4 | _(none)_ | Prefix | `location /api/` | Longest prefix wins (but only after regex check) |
+
+### Evaluation Algorithm
+
+1. Check all **prefix** locations, remember the **longest** match
+2. If longest match has `^~` modifier → use it, stop
+3. Check **regex** locations in config-file order → first match wins
+4. If no regex matches → use the longest prefix from step 1
+5. `= /path` is checked first and wins immediately if matched
+
+### Example
+
+```nginx
+location = /             { }  # Only exact "/"
+location /               { }  # Catch-all prefix
+location /api/           { }  # Prefix: /api/*
+location ^~ /static/     { }  # Prefix, skip regex: /static/*
+location ~ \.php$        { }  # Regex: any .php file
+location ~* \.(gif|jpg)$ { }  # Case-insensitive regex: images
+```
+
+| Request URI | Matched Location | Why |
+|-------------|-----------------|-----|
+| `/` | `= /` | Exact match (priority 1) |
+| `/index.html` | `/` | Longest prefix, no regex match |
+| `/api/users` | `/api/` | Longest prefix, no regex match |
+| `/static/logo.png` | `^~ /static/` | `^~` skips regex check |
+| `/app/index.php` | `~ \.php$` | Regex beats prefix |
+| `/photos/cat.jpg` | `~* \.(gif\|jpg)$` | Regex beats prefix |
+
+---
+
+## Common Configurations
+
+### SPA Routing (React, Vue, Angular)
+
+```nginx
+server {
+    listen 80;
+    server_name app.example.com;
+
+    root /var/www/app/dist;
+    index index.html;
+
+    # Serve static files directly, fall back to index.html for SPA routes
+    location / {
+        try_files $uri $uri/ /index.html;
+    }
+
+    # Cache static assets aggressively
+    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
+        expires 1y;
+        add_header Cache-Control "public, immutable";
+    }
+}
+```
+
+### WebSocket Proxy
+
+```nginx
+location /ws/ {
+    proxy_pass http://127.0.0.1:3000;
+    proxy_http_version 1.1;
+    proxy_set_header Upgrade $http_upgrade;
+    proxy_set_header Connection "upgrade";
+    proxy_set_header Host $host;
+    proxy_set_header X-Real-IP $remote_addr;
+    proxy_read_timeout 86400s;  # Keep WebSocket alive for 24h
+    proxy_send_timeout 86400s;
+}
+```
+
+### Rate Limiting
+
+```nginx
+# Define zone: 10MB shared memory, 10 requests/second per IP
+limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
+
+server {
+    location /api/ {
+        # Allow burst of 20, process excess without delay up to burst
+        limit_req zone=api burst=20 nodelay;
+        limit_req_status 429;
+
+        proxy_pass http://backend;
+    }
+}
+```
+
+### Gzip Compression
+
+```nginx
+http {
+    gzip on;
+    gzip_comp_level 5;          # Balance CPU vs compression (1-9)
+    gzip_min_length 256;        # Don't compress tiny responses
+    gzip_vary on;               # Vary: Accept-Encoding header
+    gzip_proxied any;           # Compress proxied responses too
+    gzip_types
+        text/plain
+        text/css
+        text/javascript
+        application/javascript
+        application/json
+        application/xml
+        application/xml+rss
+        image/svg+xml;
+}
+```
+
+### Static File Serving
+
+```nginx
+location /static/ {
+    alias /var/www/static/;     # Note: alias, not root (includes /static/ path)
+    expires 30d;
+    add_header Cache-Control "public, no-transform";
+
+    # Disable access log for static files
+    access_log off;
+
+    # Enable open file cache
+    open_file_cache max=1000 inactive=20s;
+    open_file_cache_valid 30s;
+    open_file_cache_min_uses 2;
+}
+```
+
+### CORS Headers
+
+```nginx
+location /api/ {
+    # CORS headers
+    add_header Access-Control-Allow-Origin "https://app.example.com" always;
+    add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
+    add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
+    add_header Access-Control-Max-Age 86400 always;
+
+    # Handle preflight requests
+    if ($request_method = OPTIONS) {
+        return 204;
+    }
+
+    proxy_pass http://backend;
+}
+```
+
+---
+
+## Docker Patterns
+
+### Nginx as Reverse Proxy in Docker Compose
+
+```yaml
+# docker-compose.yml
+services:
+  nginx:
+    image: nginx:alpine
+    ports:
+      - "80:80"
+      - "443:443"
+    volumes:
+      - ./nginx.conf:/etc/nginx/nginx.conf:ro
+      - ./certs:/etc/nginx/certs:ro
+    depends_on:
+      - app
+    networks:
+      - webnet
+
+  app:
+    build: .
+    expose:
+      - "3000"    # Internal only, not published to host
+    networks:
+      - webnet
+
+networks:
+  webnet:
+```
+
+```nginx
+# nginx.conf for docker-compose (use service name as hostname)
+upstream app_backend {
+    server app:3000;    # Docker DNS resolves service name
+}
+
+server {
+    listen 80;
+    location / {
+        proxy_pass http://app_backend;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+}
+```
+
+### Multi-Stage Build with Static Assets
+
+```dockerfile
+# Stage 1: Build frontend
+FROM node:20-alpine AS builder
+WORKDIR /app
+COPY package*.json ./
+RUN npm ci
+COPY . .
+RUN npm run build
+
+# Stage 2: Serve with nginx
+FROM nginx:alpine
+COPY --from=builder /app/dist /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+EXPOSE 80
+```
+
+```nginx
+# nginx.conf for containerized SPA
+server {
+    listen 80;
+    root /usr/share/nginx/html;
+    index index.html;
+
+    # SPA routing
+    location / {
+        try_files $uri $uri/ /index.html;
+    }
+
+    # Health check endpoint
+    location /health {
+        access_log off;
+        return 200 "OK\n";
+        add_header Content-Type text/plain;
+    }
+
+    # Cache busted assets
+    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
+        expires 1y;
+        add_header Cache-Control "public, immutable";
+    }
+}
+```
+
+---
+
+## Common Gotchas
+
+| Gotcha | Why | Fix |
+|--------|-----|-----|
+| **Trailing slash in `proxy_pass`** | `proxy_pass http://backend` keeps `/api/users` as-is; `proxy_pass http://backend/` strips the matched `location` prefix | Be intentional: with `/` to strip prefix, without to preserve |
+| **Missing proxy headers** | Backend sees nginx's IP, not the client's. Breaks auth, logging, and geo detection | Always set `X-Real-IP`, `X-Forwarded-For`, `X-Forwarded-Proto`, and `Host` |
+| **Buffer size errors (502)** | Large headers (cookies, JWTs) exceed default buffer sizes | Increase `proxy_buffer_size 8k;` and `proxy_buffers 4 16k;` |
+| **`worker_connections` too low** | Default is 512 or 1024; each client uses 2 connections (client + upstream) | Set `worker_connections 4096;` and raise `worker_rlimit_nofile` |
+| **`try_files` with `proxy_pass`** | `try_files` and `proxy_pass` in the same `location` don't work as expected | Use `try_files $uri @backend;` with a named location for proxy |
+| **"if is evil"** | `if` inside `location` creates an implicit nested location, breaking directives | Use `map` for variable-based logic; reserve `if` for `return`/`rewrite` only |
+| **Resolver for dynamic upstreams** | Variables in `proxy_pass` (e.g., `$upstream`) bypass startup DNS resolution | Add `resolver 127.0.0.11 valid=30s;` (Docker) or `resolver 1.1.1.1;` |
+| **Missing `index` directive** | Returns 403 Forbidden when accessing a directory instead of index file | Add `index index.html;` in `server` or `location` block |
+| **Permission denied on socket** | Nginx worker can't read the upstream Unix socket | Ensure nginx user is in the socket's group; `chmod 660` the socket |
+| **Duplicate `Content-Encoding` with gzip** | Upstream already compresses + nginx gzip double-compresses | Use `gzip_proxied` carefully or `proxy_set_header Accept-Encoding "";` |
+| **`add_header` not inherited** | Adding ANY `add_header` in a `location` discards ALL parent `add_header` directives | Redeclare all headers in the child block, or use `include` for shared headers |
+| **`alias` vs `root` confusion** | `root` appends the location path; `alias` replaces it. `/img/` + `root /data` = `/data/img/`; `alias /data/` = `/data/` | Use `alias` when location path shouldn't appear in filesystem path |
+
+---
+
+## Reference Files
+
+| File | Contents | Lines |
+|------|----------|-------|
+| [reverse-proxy.md](references/reverse-proxy.md) | Upstream blocks, load balancing, proxy caching, WebSocket/gRPC, timeouts, real-world configs | ~650 |
+| [ssl-security.md](references/ssl-security.md) | TLS config, Let's Encrypt, HSTS, OCSP, security headers, rate limiting, mTLS | ~550 |
+| [performance.md](references/performance.md) | Worker tuning, compression, caching, HTTP/2+3, static files, monitoring | ~550 |
+
+---
+
+## See Also
+
+- **docker-ops** - Container orchestration, docker-compose patterns
+- **security-ops** - Application security, authentication patterns
+- **ci-cd-ops** - Deployment pipelines, zero-downtime deploys
+- [Nginx official docs](https://nginx.org/en/docs/)
+- [Mozilla SSL Configuration Generator](https://ssl-config.mozilla.org/)
+- [Nginx Config Generator](https://www.digitalocean.com/community/tools/nginx)

+ 0 - 0
skills/nginx-ops/assets/.gitkeep


+ 951 - 0
skills/nginx-ops/references/performance.md

@@ -0,0 +1,951 @@
+# Performance Reference
+
+Comprehensive guide to Nginx performance optimization: worker tuning, connection handling, compression, caching, HTTP/2 and HTTP/3, static file serving, and monitoring.
+
+---
+
+## Table of Contents
+
+1. [Worker Configuration](#worker-configuration)
+2. [Event Model](#event-model)
+3. [Connection Handling](#connection-handling)
+4. [Sendfile and TCP Optimizations](#sendfile-and-tcp-optimizations)
+5. [Compression](#compression)
+6. [Open File Cache](#open-file-cache)
+7. [Static File Serving](#static-file-serving)
+8. [Proxy Caching](#proxy-caching)
+9. [FastCGI Caching](#fastcgi-caching)
+10. [Microcaching](#microcaching)
+11. [HTTP/2](#http2)
+12. [HTTP/3 (QUIC)](#http3-quic)
+13. [Connection Draining](#connection-draining)
+14. [Monitoring](#monitoring)
+
+---
+
+## Worker Configuration
+
+### Worker Processes
+
+```nginx
+# Auto-detect CPU cores (recommended)
+worker_processes auto;
+
+# Or set explicitly (match CPU core count)
+# worker_processes 4;
+
+# Pin workers to specific CPUs (optional, advanced)
+worker_cpu_affinity auto;
+# Or manually: worker_cpu_affinity 0001 0010 0100 1000;
+```
+
+### Worker Connections
+
+```nginx
+events {
+    # Maximum simultaneous connections per worker
+    # Total capacity = worker_processes * worker_connections
+    worker_connections 4096;
+
+    # Accept multiple connections at once
+    multi_accept on;
+}
+```
+
+### File Descriptor Limits
+
+Each connection uses at least one file descriptor (two when proxying).
+
+```nginx
+# Maximum open files per worker process
+# Should be >= 2 * worker_connections
+worker_rlimit_nofile 65535;
+```
+
+Also set OS-level limits:
+
+```bash
+# /etc/security/limits.conf
+nginx soft nofile 65535
+nginx hard nofile 65535
+
+# Or /etc/systemd/system/nginx.service.d/override.conf
+[Service]
+LimitNOFILE=65535
+```
+
+### Sizing Guidelines
+
+| Traffic Level | `worker_processes` | `worker_connections` | `worker_rlimit_nofile` | Total Capacity |
+|--------------|-------------------|---------------------|----------------------|----------------|
+| Low (< 1K rps) | auto (2-4) | 1024 | 4096 | 2K-4K connections |
+| Medium (1K-10K rps) | auto (4-8) | 4096 | 16384 | 16K-32K connections |
+| High (10K-100K rps) | auto (8-16) | 8192 | 65535 | 64K-128K connections |
+
+---
+
+## Event Model
+
+### Linux (epoll)
+
+```nginx
+events {
+    use epoll;
+    worker_connections 4096;
+    multi_accept on;
+}
+```
+
+`epoll` is the most efficient event model on Linux, using O(1) event notification.
+
+### BSD/macOS (kqueue)
+
+```nginx
+events {
+    use kqueue;
+    worker_connections 4096;
+    multi_accept on;
+}
+```
+
+### Event Model Comparison
+
+| Model | OS | Scalability | Notes |
+|-------|-----|-------------|-------|
+| `epoll` | Linux 2.6+ | Excellent | Default and best for Linux |
+| `kqueue` | FreeBSD, macOS | Excellent | Default for BSD systems |
+| `select` | All | Poor | Legacy, limited to 1024 fds |
+| `poll` | All | Fair | Better than select, still O(n) |
+
+Nginx auto-selects the best available model. Explicit `use` is optional but recommended for clarity.
+
+---
+
+## Connection Handling
+
+### Keepalive Configuration
+
+```nginx
+http {
+    # Client-facing keepalive
+    keepalive_timeout 65s;        # Close idle connections after 65s
+    keepalive_requests 1000;      # Max requests per keepalive connection
+
+    # Reset timed-out connections (free resources faster)
+    reset_timedout_connection on;
+
+    # Client timeouts
+    client_body_timeout 12s;      # Time to receive request body
+    client_header_timeout 12s;    # Time to receive request headers
+    send_timeout 10s;             # Time between successive writes to client
+
+    # Limit request/header sizes
+    client_max_body_size 10m;     # Max upload size
+    client_body_buffer_size 16k;  # Buffer for request body
+    client_header_buffer_size 1k; # Buffer for request headers
+    large_client_header_buffers 4 8k;  # For large headers (cookies, etc.)
+}
+```
+
+### Keepalive Tuning
+
+| Scenario | `keepalive_timeout` | `keepalive_requests` | Rationale |
+|----------|--------------------|--------------------|-----------|
+| API server | 30-60s | 1000-10000 | Frequent requests, reuse connections |
+| Static files | 15-30s | 100-500 | Quick downloads, then disconnect |
+| WebSocket | 3600s+ | N/A | Long-lived connections |
+| High-traffic | 15-30s | 100 | Free connections sooner |
+
+---
+
+## Sendfile and TCP Optimizations
+
+### sendfile
+
+Transfers files directly in kernel space without copying to userspace. Significant performance improvement for static files.
+
+```nginx
+http {
+    # Enable kernel-level file transfer
+    sendfile on;
+
+    # Send headers and beginning of file in one packet
+    tcp_nopush on;
+
+    # Disable Nagle algorithm (send small packets immediately)
+    tcp_nodelay on;
+}
+```
+
+### How They Work Together
+
+| Directive | Purpose | When Active |
+|-----------|---------|-------------|
+| `sendfile on` | Zero-copy file transfer via kernel | Serving static files |
+| `tcp_nopush on` | Batch headers + file data into full packets | With sendfile, before last packet |
+| `tcp_nodelay on` | Send last packet immediately (no 200ms Nagle delay) | After tcp_nopush releases last packet |
+
+The combination `sendfile on; tcp_nopush on; tcp_nodelay on;` is optimal:
+1. `sendfile` transfers the file efficiently
+2. `tcp_nopush` fills packets completely for the bulk of the transfer
+3. `tcp_nodelay` sends the final partial packet without waiting
+
+---
+
+## Compression
+
+### Gzip Configuration
+
+```nginx
+http {
+    # Enable gzip compression
+    gzip on;
+
+    # Compression level (1-9, higher = smaller but more CPU)
+    # 5-6 is a good balance
+    gzip_comp_level 5;
+
+    # Minimum response size to compress (skip tiny responses)
+    gzip_min_length 256;
+
+    # Add Vary: Accept-Encoding header
+    gzip_vary on;
+
+    # Compress proxied responses
+    gzip_proxied any;
+
+    # MIME types to compress (text/html is always compressed)
+    gzip_types
+        text/plain
+        text/css
+        text/javascript
+        text/xml
+        application/javascript
+        application/json
+        application/xml
+        application/xml+rss
+        application/atom+xml
+        application/vnd.ms-fontobject
+        font/opentype
+        image/svg+xml
+        image/x-icon;
+
+    # Disable gzip for old browsers (IE6)
+    gzip_disable "msie6";
+
+    # Buffer size for gzip
+    gzip_buffers 16 8k;
+}
+```
+
+### Gzip Level Comparison
+
+| Level | Compression Ratio | CPU Usage | Best For |
+|-------|------------------|-----------|----------|
+| 1 | Low (~60%) | Minimal | Very high traffic, CPU-bound |
+| 3-4 | Medium (~70%) | Low | Good default for most sites |
+| 5-6 | Good (~75%) | Moderate | Recommended balance |
+| 9 | Maximum (~78%) | High | Rarely worth it over level 6 |
+
+The diminishing returns above level 5-6 are significant: going from level 5 to 9 might save 3% more bytes but costs 3-4x more CPU.
+
+### Brotli Compression
+
+Brotli achieves 15-20% better compression than gzip at similar CPU cost. Requires the `ngx_brotli` module.
+
+```nginx
+# Requires: ngx_brotli module
+# Install: https://github.com/google/ngx_brotli
+
+http {
+    # Brotli dynamic compression
+    brotli on;
+    brotli_comp_level 6;
+    brotli_min_length 256;
+    brotli_types
+        text/plain
+        text/css
+        text/javascript
+        application/javascript
+        application/json
+        application/xml
+        image/svg+xml;
+
+    # Serve pre-compressed .br files if available
+    brotli_static on;
+
+    # Keep gzip as fallback (not all clients support brotli)
+    gzip on;
+    gzip_comp_level 5;
+    gzip_types text/plain text/css application/javascript application/json;
+}
+```
+
+### Pre-Compressed Files
+
+Serve pre-compressed files to avoid runtime compression overhead.
+
+```nginx
+http {
+    # Serve .gz files if they exist
+    gzip_static on;
+
+    # Serve .br files if they exist (requires brotli module)
+    brotli_static on;
+}
+```
+
+Build step to pre-compress:
+
+```bash
+# Pre-compress static assets during build
+fd -e js -e css -e html -e svg -e json dist/ -x gzip -k -9 {}
+fd -e js -e css -e html -e svg -e json dist/ -x brotli -k {}
+```
+
+---
+
+## Open File Cache
+
+Cache file descriptors, metadata, and lookup results to reduce filesystem calls.
+
+```nginx
+http {
+    # Cache up to 1000 file descriptors, remove unused after 20s
+    open_file_cache max=1000 inactive=20s;
+
+    # How often to check if cached info is still valid
+    open_file_cache_valid 30s;
+
+    # Minimum number of accesses before caching
+    open_file_cache_min_uses 2;
+
+    # Cache file lookup errors (e.g., file not found)
+    open_file_cache_errors on;
+}
+```
+
+### When to Use
+
+| Scenario | Recommended |
+|----------|-------------|
+| Serving many static files | Yes |
+| Reverse proxy only | No (not needed) |
+| High-traffic static site | Yes, increase max |
+| Few large files | Marginal benefit |
+
+---
+
+## Static File Serving
+
+### Optimized Static File Configuration
+
+```nginx
+server {
+    listen 443 ssl http2;
+    server_name static.example.com;
+
+    root /var/www/static;
+
+    # Performance fundamentals
+    sendfile on;
+    tcp_nopush on;
+    tcp_nodelay on;
+
+    # File descriptor caching
+    open_file_cache max=2000 inactive=30s;
+    open_file_cache_valid 60s;
+    open_file_cache_min_uses 2;
+    open_file_cache_errors on;
+
+    # Immutable hashed assets (e.g., app.a3b4c5d6.js)
+    location ~* \.[a-f0-9]{8,}\.(js|css|png|jpg|jpeg|gif|svg|woff2?)$ {
+        expires max;
+        add_header Cache-Control "public, immutable";
+        access_log off;
+    }
+
+    # Regular static assets
+    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
+        expires 30d;
+        add_header Cache-Control "public, no-transform";
+        access_log off;
+    }
+
+    # HTML files (shorter cache, must revalidate)
+    location ~* \.html$ {
+        expires 1h;
+        add_header Cache-Control "public, must-revalidate";
+    }
+
+    # Enable ETag for cache validation
+    etag on;
+}
+```
+
+### Cache-Control Header Reference
+
+| Directive | Purpose | Use Case |
+|-----------|---------|----------|
+| `public` | Any cache can store | Static assets |
+| `private` | Only browser can store | User-specific content |
+| `no-cache` | Must revalidate before using | HTML pages |
+| `no-store` | Don't cache at all | Sensitive data |
+| `max-age=N` | Cache for N seconds | All cacheable content |
+| `immutable` | Never changes (skip revalidation) | Hashed filenames |
+| `must-revalidate` | Don't serve stale, even if disconnected | Critical content |
+| `stale-while-revalidate=N` | Serve stale while fetching fresh | UX optimization |
+
+### Expires Directive Shortcuts
+
+```nginx
+# Specific durations
+expires 30d;              # 30 days
+expires 1h;               # 1 hour
+expires 30m;              # 30 minutes
+expires max;              # Far future (practically forever)
+expires off;              # Don't add Expires header
+expires -1;               # Already expired (forces revalidation)
+expires epoch;            # Set to Unix epoch (Jan 1, 1970)
+expires modified +24h;   # 24h after file modification time
+```
+
+---
+
+## Proxy Caching
+
+### Production Proxy Cache Configuration
+
+```nginx
+http {
+    # Define cache storage
+    proxy_cache_path /var/cache/nginx/proxy
+        levels=1:2
+        keys_zone=proxy_cache:20m      # 20MB metadata (~160K keys)
+        max_size=20g                    # 20GB max disk usage
+        inactive=7d                    # Remove unused items after 7 days
+        use_temp_path=off              # Write directly to cache dir
+        manager_files=100              # Files to process per cache manager cycle
+        manager_threshold=200ms;       # Max time for cache manager cycle
+
+    server {
+        location / {
+            proxy_pass http://backend;
+
+            # Enable caching
+            proxy_cache proxy_cache;
+
+            # Cache key (determines what is considered a unique response)
+            proxy_cache_key "$scheme$request_method$host$request_uri";
+
+            # Cache durations by status code
+            proxy_cache_valid 200 301 302 1h;
+            proxy_cache_valid 404         1m;
+
+            # Serve stale content during backend errors
+            proxy_cache_use_stale error timeout updating
+                                  http_500 http_502 http_503 http_504;
+
+            # Background refresh
+            proxy_cache_background_update on;
+
+            # Prevent thundering herd (only one request refreshes)
+            proxy_cache_lock on;
+            proxy_cache_lock_timeout 5s;
+            proxy_cache_lock_age 5s;
+
+            # Skip caching for logged-in users
+            proxy_cache_bypass $cookie_session $http_authorization;
+            proxy_no_cache $cookie_session $http_authorization;
+
+            # Show cache status in response header
+            add_header X-Cache-Status $upstream_cache_status always;
+
+            # Minimum uses before caching (prevent caching one-time requests)
+            proxy_cache_min_uses 2;
+        }
+    }
+}
+```
+
+### Cache Status Values
+
+The `$upstream_cache_status` variable contains:
+
+| Value | Meaning |
+|-------|---------|
+| `HIT` | Served from cache |
+| `MISS` | Not in cache, fetched from backend |
+| `BYPASS` | Cache was bypassed (proxy_cache_bypass matched) |
+| `EXPIRED` | Cache entry expired, fetched fresh from backend |
+| `STALE` | Served stale (backend unavailable, using proxy_cache_use_stale) |
+| `UPDATING` | Stale entry served while background update in progress |
+| `REVALIDATED` | Cache entry was revalidated with If-Modified-Since |
+
+### Cache Key Design
+
+```nginx
+# Default: includes method, scheme, host, and URI
+proxy_cache_key "$scheme$request_method$host$request_uri";
+
+# Include query parameters explicitly
+proxy_cache_key "$host$request_uri$is_args$args";
+
+# Include a custom header (e.g., API version)
+proxy_cache_key "$host$request_uri$http_x_api_version";
+
+# Include cookie for per-user caching (use carefully!)
+proxy_cache_key "$host$request_uri$cookie_lang";
+
+# Separate cache for mobile vs desktop
+proxy_cache_key "$host$request_uri$http_user_agent_class";
+```
+
+---
+
+## FastCGI Caching
+
+For PHP-FPM and other FastCGI applications.
+
+```nginx
+http {
+    # FastCGI cache zone
+    fastcgi_cache_path /var/cache/nginx/fastcgi
+        levels=1:2
+        keys_zone=fcgi_cache:10m
+        max_size=5g
+        inactive=60m
+        use_temp_path=off;
+
+    server {
+        # Skip cache for logged-in users and POST requests
+        set $skip_cache 0;
+
+        # Don't cache POST requests
+        if ($request_method = POST) {
+            set $skip_cache 1;
+        }
+
+        # Don't cache URLs with query strings
+        if ($query_string != "") {
+            set $skip_cache 1;
+        }
+
+        # Don't cache admin pages (WordPress example)
+        if ($request_uri ~* "/wp-admin/|/wp-login.php") {
+            set $skip_cache 1;
+        }
+
+        # Don't cache logged-in users (WordPress)
+        if ($http_cookie ~* "wordpress_logged_in") {
+            set $skip_cache 1;
+        }
+
+        location ~ \.php$ {
+            fastcgi_pass php-fpm;
+            fastcgi_index index.php;
+            fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
+            include fastcgi_params;
+
+            # Enable FastCGI cache
+            fastcgi_cache fcgi_cache;
+            fastcgi_cache_key "$scheme$request_method$host$request_uri";
+            fastcgi_cache_valid 200 60m;
+            fastcgi_cache_valid 301 302 10m;
+            fastcgi_cache_valid 404 1m;
+
+            # Skip cache conditions
+            fastcgi_cache_bypass $skip_cache;
+            fastcgi_no_cache $skip_cache;
+
+            # Serve stale during errors
+            fastcgi_cache_use_stale error timeout updating
+                                    http_500 http_502 http_503;
+            fastcgi_cache_background_update on;
+            fastcgi_cache_lock on;
+
+            # Cache status header
+            add_header X-FastCGI-Cache $upstream_cache_status;
+        }
+    }
+}
+```
+
+---
+
+## Microcaching
+
+Cache dynamic content for very short durations (1-5 seconds) to absorb traffic spikes. Even a 1-second cache dramatically reduces backend load under high traffic.
+
+```nginx
+http {
+    proxy_cache_path /var/cache/nginx/micro
+        levels=1:2
+        keys_zone=micro_cache:5m
+        max_size=1g
+        inactive=1m
+        use_temp_path=off;
+
+    server {
+        location / {
+            proxy_pass http://backend;
+
+            # Enable microcaching
+            proxy_cache micro_cache;
+            proxy_cache_valid 200 1s;    # Cache for just 1 second
+
+            # Serve stale while updating
+            proxy_cache_use_stale updating error timeout;
+            proxy_cache_background_update on;
+
+            # Only one request triggers backend fetch
+            proxy_cache_lock on;
+            proxy_cache_lock_timeout 1s;
+
+            # Don't cache if backend sets Cache-Control: no-cache
+            proxy_cache_bypass $http_cache_control;
+
+            # Don't cache for authenticated users
+            proxy_cache_bypass $cookie_session;
+            proxy_no_cache $cookie_session;
+
+            add_header X-Cache-Status $upstream_cache_status;
+        }
+    }
+}
+```
+
+### Microcaching Impact
+
+| Requests/sec | Without Cache | 1s Microcache | Reduction |
+|-------------|---------------|---------------|-----------|
+| 100 | 100 backend hits/s | 1 backend hit/s | 99% |
+| 1,000 | 1,000 backend hits/s | 1 backend hit/s | 99.9% |
+| 10,000 | 10,000 backend hits/s | 1 backend hit/s | 99.99% |
+
+---
+
+## HTTP/2
+
+### Basic HTTP/2 Configuration
+
+```nginx
+server {
+    # http2 directive (Nginx 1.25.1+)
+    listen 443 ssl;
+    http2 on;
+
+    # For older Nginx versions:
+    # listen 443 ssl http2;
+
+    server_name example.com;
+
+    ssl_certificate     /etc/nginx/certs/fullchain.pem;
+    ssl_certificate_key /etc/nginx/certs/privkey.pem;
+
+    # HTTP/2 specific settings
+    http2_max_concurrent_streams 128;
+    http2_recv_buffer_size 256k;
+}
+```
+
+### HTTP/2 Benefits
+
+| Feature | HTTP/1.1 | HTTP/2 |
+|---------|---------|--------|
+| Multiplexing | 6 connections per domain | Unlimited streams on 1 connection |
+| Header compression | None | HPACK compression |
+| Server push | Not possible | Supported (but deprecated) |
+| Stream priority | N/A | Priority hints |
+| Binary protocol | Text-based | Binary framing |
+
+### HTTP/2 Server Push (Deprecated)
+
+Server push was removed from Chrome and is generally considered deprecated. Use `<link rel="preload">` or `103 Early Hints` instead.
+
+```nginx
+# 103 Early Hints (modern alternative to server push)
+location / {
+    # Send early hints before the main response
+    add_header Link "</style.css>; rel=preload; as=style" early;
+    add_header Link "</app.js>; rel=preload; as=script" early;
+
+    proxy_pass http://backend;
+}
+```
+
+---
+
+## HTTP/3 (QUIC)
+
+HTTP/3 uses QUIC (UDP-based transport) for faster connection establishment and better performance on lossy networks.
+
+### Basic HTTP/3 Configuration
+
+Requires Nginx 1.25.0+ compiled with QUIC support, or nginx-quic branch.
+
+```nginx
+server {
+    # Standard HTTPS (HTTP/1.1 and HTTP/2)
+    listen 443 ssl;
+    http2 on;
+
+    # HTTP/3 via QUIC (UDP)
+    listen 443 quic reuseport;
+
+    server_name example.com;
+
+    ssl_certificate     /etc/nginx/certs/fullchain.pem;
+    ssl_certificate_key /etc/nginx/certs/privkey.pem;
+
+    # Required: TLS 1.3 only for QUIC
+    ssl_protocols TLSv1.2 TLSv1.3;
+
+    # Advertise HTTP/3 support via Alt-Svc header
+    add_header Alt-Svc 'h3=":443"; ma=86400' always;
+
+    # QUIC-specific settings
+    quic_retry on;                 # Enable address validation
+    ssl_early_data on;             # Enable 0-RTT (with replay protection)
+
+    # Required for QUIC
+    ssl_session_tickets on;
+}
+```
+
+### Firewall Configuration for QUIC
+
+```bash
+# Allow UDP port 443 for QUIC
+sudo iptables -A INPUT -p udp --dport 443 -j ACCEPT
+
+# Or with firewalld
+sudo firewall-cmd --permanent --add-port=443/udp
+sudo firewall-cmd --reload
+
+# Or with ufw
+sudo ufw allow 443/udp
+```
+
+### HTTP/3 Benefits
+
+| Feature | HTTP/2 (TCP) | HTTP/3 (QUIC) |
+|---------|-------------|---------------|
+| Connection setup | 2-3 RTT (TCP + TLS) | 0-1 RTT |
+| Head-of-line blocking | Yes (TCP level) | No (per-stream) |
+| Connection migration | No (IP changes break) | Yes (connection ID) |
+| Packet loss handling | All streams blocked | Only affected stream |
+| 0-RTT resumption | TLS 1.3 only | Built-in |
+
+---
+
+## Connection Draining
+
+### Graceful Reload
+
+```bash
+# Graceful reload: new workers start, old workers finish existing requests
+sudo nginx -s reload
+
+# What happens:
+# 1. Master process reads new config
+# 2. Starts new worker processes with new config
+# 3. Old workers stop accepting new connections
+# 4. Old workers finish processing existing requests
+# 5. Old workers exit
+```
+
+### Worker Shutdown Timeout
+
+```nginx
+# Maximum time for old workers to finish requests during reload
+# After this timeout, old workers are forcefully terminated
+worker_shutdown_timeout 30s;
+```
+
+### Zero-Downtime Deployment
+
+```bash
+# 1. Deploy new application code
+# 2. Signal nginx to reload config
+sudo nginx -t && sudo nginx -s reload
+
+# Or with systemd
+sudo nginx -t && sudo systemctl reload nginx
+```
+
+### Upstream Draining
+
+```nginx
+upstream backend {
+    server 127.0.0.1:3000;
+
+    # Mark server as draining (finish existing, no new)
+    server 127.0.0.1:3001 down;    # Use 'down' to stop new traffic
+
+    server 127.0.0.1:3002;
+}
+```
+
+---
+
+## Monitoring
+
+### stub_status Module
+
+```nginx
+server {
+    listen 8080;
+
+    # Restrict to internal access
+    allow 127.0.0.1;
+    allow 10.0.0.0/8;
+    deny all;
+
+    location /nginx_status {
+        stub_status;
+    }
+}
+```
+
+Output:
+
+```
+Active connections: 291
+server accepts handled requests
+ 16630948 16630948 31070465
+Reading: 6 Writing: 179 Waiting: 106
+```
+
+| Metric | Meaning |
+|--------|---------|
+| Active connections | Current active client connections (including waiting) |
+| accepts | Total accepted connections |
+| handled | Total handled connections (should equal accepts) |
+| requests | Total client requests |
+| Reading | Connections where nginx is reading the request header |
+| Writing | Connections where nginx is writing response to client |
+| Waiting | Idle keepalive connections |
+
+### Request Timing Variables
+
+Use these in log formats for performance monitoring.
+
+```nginx
+http {
+    log_format performance '$remote_addr - $remote_user [$time_local] '
+                           '"$request" $status $body_bytes_sent '
+                           '"$http_referer" "$http_user_agent" '
+                           'rt=$request_time '
+                           'urt=$upstream_response_time '
+                           'uct=$upstream_connect_time '
+                           'uht=$upstream_header_time '
+                           'cs=$upstream_cache_status';
+
+    access_log /var/log/nginx/performance.log performance;
+}
+```
+
+### Timing Variable Reference
+
+| Variable | Meaning |
+|----------|---------|
+| `$request_time` | Total time from first byte read to last byte sent (seconds, ms resolution) |
+| `$upstream_response_time` | Time from establishing upstream connection to receiving last byte |
+| `$upstream_connect_time` | Time to establish connection to upstream server |
+| `$upstream_header_time` | Time from connection to receiving response headers from upstream |
+| `$upstream_cache_status` | HIT, MISS, BYPASS, EXPIRED, STALE, UPDATING, REVALIDATED |
+
+### Conditional Logging
+
+```nginx
+# Only log slow requests (> 1 second)
+map $request_time $loggable_slow {
+    ~^[0-9]*\.[0-9]$  0;    # < 1 second
+    default            1;    # >= 1 second
+}
+
+access_log /var/log/nginx/slow.log performance if=$loggable_slow;
+
+# Don't log health checks
+map $request_uri $loggable {
+    /health     0;
+    /ping       0;
+    default     1;
+}
+
+access_log /var/log/nginx/access.log combined if=$loggable;
+```
+
+### JSON Log Format
+
+Easier to parse with log aggregation tools (ELK, Loki, etc.).
+
+```nginx
+log_format json_combined escape=json
+    '{'
+        '"time":"$time_iso8601",'
+        '"remote_addr":"$remote_addr",'
+        '"request_method":"$request_method",'
+        '"request_uri":"$request_uri",'
+        '"status":$status,'
+        '"body_bytes_sent":$body_bytes_sent,'
+        '"request_time":$request_time,'
+        '"upstream_response_time":"$upstream_response_time",'
+        '"upstream_cache_status":"$upstream_cache_status",'
+        '"http_referrer":"$http_referer",'
+        '"http_user_agent":"$http_user_agent",'
+        '"server_name":"$server_name"'
+    '}';
+
+access_log /var/log/nginx/access.json json_combined;
+```
+
+### Integration with Prometheus
+
+Use the `nginx-prometheus-exporter` for Prometheus/Grafana monitoring.
+
+```bash
+# Run nginx-prometheus-exporter
+./nginx-prometheus-exporter -nginx.scrape-uri=http://127.0.0.1:8080/nginx_status
+```
+
+Or use the VTS (Virtual Host Traffic Status) module for more detailed metrics:
+
+```nginx
+# Requires ngx_http_vhost_traffic_status_module
+http {
+    vhost_traffic_status_zone;
+
+    server {
+        listen 8080;
+
+        location /status {
+            vhost_traffic_status_display;
+            vhost_traffic_status_display_format prometheus;
+        }
+    }
+}
+```
+
+### Quick Health Check Script
+
+```bash
+#!/bin/bash
+# nginx-health.sh - Quick nginx health check
+
+NGINX_STATUS="http://127.0.0.1:8080/nginx_status"
+RESPONSE=$(curl -s "$NGINX_STATUS")
+
+ACTIVE=$(echo "$RESPONSE" | rg -o 'Active connections: (\d+)' -r '$1')
+WAITING=$(echo "$RESPONSE" | rg -o 'Waiting: (\d+)' -r '$1')
+READING=$(echo "$RESPONSE" | rg -o 'Reading: (\d+)' -r '$1')
+WRITING=$(echo "$RESPONSE" | rg -o 'Writing: (\d+)' -r '$1')
+
+echo "Active: $ACTIVE | Reading: $READING | Writing: $WRITING | Waiting: $WAITING"
+
+# Alert if active connections exceed threshold
+if [ "$ACTIVE" -gt 5000 ]; then
+    echo "WARNING: High connection count: $ACTIVE"
+fi
+```

+ 1091 - 0
skills/nginx-ops/references/reverse-proxy.md

@@ -0,0 +1,1091 @@
+# Reverse Proxy Reference
+
+Comprehensive guide to Nginx reverse proxy configuration: upstream blocks, load balancing algorithms, proxy headers, WebSocket and gRPC proxying, caching, and production-ready configurations.
+
+---
+
+## Table of Contents
+
+1. [Upstream Blocks](#upstream-blocks)
+2. [Load Balancing Algorithms](#load-balancing-algorithms)
+3. [Health Checks](#health-checks)
+4. [Proxy Headers](#proxy-headers)
+5. [WebSocket Proxy](#websocket-proxy)
+6. [gRPC Proxy](#grpc-proxy)
+7. [Proxy Caching](#proxy-caching)
+8. [Proxy Buffering](#proxy-buffering)
+9. [Keepalive Connections](#keepalive-connections)
+10. [Timeout Configuration](#timeout-configuration)
+11. [Real-World Configurations](#real-world-configurations)
+
+---
+
+## Upstream Blocks
+
+An `upstream` block defines a group of backend servers that Nginx can proxy requests to.
+
+### Basic Upstream
+
+```nginx
+upstream backend {
+    server 127.0.0.1:3000;
+    server 127.0.0.1:3001;
+    server 127.0.0.1:3002;
+}
+
+server {
+    listen 80;
+    location / {
+        proxy_pass http://backend;
+    }
+}
+```
+
+### Server Directive Parameters
+
+```nginx
+upstream backend {
+    # Basic server
+    server 127.0.0.1:3000;
+
+    # Weighted server - receives 3x traffic
+    server 127.0.0.1:3001 weight=3;
+
+    # Backup server - only used when all primary servers are down
+    server 127.0.0.1:3002 backup;
+
+    # Mark server as permanently unavailable
+    server 127.0.0.1:3003 down;
+
+    # Failure detection: after 3 fails within 30s, mark unavailable for 30s
+    server 127.0.0.1:3004 max_fails=3 fail_timeout=30s;
+
+    # Limit concurrent connections to this server
+    server 127.0.0.1:3005 max_conns=100;
+
+    # Unix socket backend
+    server unix:/var/run/app.sock;
+
+    # Resolve hostname (requires resolver directive)
+    server backend.service.consul resolve;
+}
+```
+
+### Parameter Reference
+
+| Parameter | Default | Description |
+|-----------|---------|-------------|
+| `weight=N` | 1 | Relative weight for weighted load balancing |
+| `max_fails=N` | 1 | Number of failed attempts before marking unavailable |
+| `fail_timeout=T` | 10s | Time to consider fails AND duration to mark unavailable |
+| `backup` | - | Only used when all non-backup servers are unavailable |
+| `down` | - | Permanently marks server as unavailable |
+| `max_conns=N` | 0 (unlimited) | Maximum concurrent connections to this server |
+| `resolve` | - | Monitor DNS changes and update upstream automatically |
+| `slow_start=T` | 0 | Gradually increase traffic to recovered server (Nginx Plus) |
+
+---
+
+## Load Balancing Algorithms
+
+### Round-Robin (Default)
+
+Distributes requests sequentially across servers. No directive needed.
+
+```nginx
+upstream backend {
+    # Round-robin is the default - no directive needed
+    server 127.0.0.1:3000;
+    server 127.0.0.1:3001;
+    server 127.0.0.1:3002;
+}
+```
+
+**Use when:** Backends are homogeneous and requests have similar processing time.
+
+### Weighted Round-Robin
+
+```nginx
+upstream backend {
+    server 127.0.0.1:3000 weight=5;   # Gets 5/8 of requests
+    server 127.0.0.1:3001 weight=2;   # Gets 2/8 of requests
+    server 127.0.0.1:3002 weight=1;   # Gets 1/8 of requests
+}
+```
+
+**Use when:** Backends have different capacity (CPU, memory).
+
+### Least Connections
+
+Routes to the server with the fewest active connections.
+
+```nginx
+upstream backend {
+    least_conn;
+    server 127.0.0.1:3000;
+    server 127.0.0.1:3001;
+    server 127.0.0.1:3002;
+}
+```
+
+**Use when:** Requests have variable processing time. Prevents slow requests from piling up on one server.
+
+### IP Hash
+
+Routes requests from the same client IP to the same backend server (session persistence).
+
+```nginx
+upstream backend {
+    ip_hash;
+    server 127.0.0.1:3000;
+    server 127.0.0.1:3001;
+    server 127.0.0.1:3002;
+}
+```
+
+**Use when:** Application requires sticky sessions and you can't use external session storage.
+
+**Caveat:** If clients are behind a NAT/proxy, many IPs map to one, causing uneven distribution.
+
+### Random
+
+Randomly selects a server for each request.
+
+```nginx
+upstream backend {
+    random;
+    server 127.0.0.1:3000;
+    server 127.0.0.1:3001;
+    server 127.0.0.1:3002;
+}
+```
+
+### Random with Two Choices
+
+Picks two servers at random, then selects the one with fewer connections (power of two choices).
+
+```nginx
+upstream backend {
+    random two least_conn;
+    server 127.0.0.1:3000;
+    server 127.0.0.1:3001;
+    server 127.0.0.1:3002;
+}
+```
+
+**Use when:** Large number of backends where least_conn coordination overhead is high.
+
+### Hash (Consistent Hashing)
+
+Map requests to servers based on a configurable key.
+
+```nginx
+upstream backend {
+    hash $request_uri consistent;
+    server 127.0.0.1:3000;
+    server 127.0.0.1:3001;
+    server 127.0.0.1:3002;
+}
+```
+
+**Use when:** You want cache affinity (same URLs always go to the same backend for better cache hit rates).
+
+The `consistent` parameter uses ketama consistent hashing, which minimizes redistribution when servers are added/removed.
+
+### Algorithm Comparison
+
+| Algorithm | Session Persistence | Even Distribution | Variable Request Time | Best For |
+|-----------|--------------------|--------------------|----------------------|----------|
+| Round-robin | No | Yes (uniform) | Poor | Homogeneous backends |
+| Weighted | No | Yes (proportional) | Poor | Mixed-capacity backends |
+| Least connections | No | Adapts to load | Good | Variable processing time |
+| IP hash | Yes (by IP) | Depends on IPs | Poor | Sticky sessions |
+| Random two | No | Good | Good | Large clusters |
+| Hash | Yes (by key) | Depends on keys | Poor | Cache affinity |
+
+---
+
+## Health Checks
+
+### Passive Health Checks (Open Source)
+
+Nginx detects unhealthy backends based on failed request attempts.
+
+```nginx
+upstream backend {
+    server 127.0.0.1:3000 max_fails=3 fail_timeout=30s;
+    server 127.0.0.1:3001 max_fails=3 fail_timeout=30s;
+    server 127.0.0.1:3002 max_fails=3 fail_timeout=30s;
+}
+```
+
+**How it works:**
+1. If a server fails `max_fails` times within `fail_timeout` seconds, it's marked unavailable
+2. After `fail_timeout` seconds, Nginx sends one test request
+3. If the test succeeds, the server is marked available again
+
+**What counts as a failure:** Controlled by `proxy_next_upstream`:
+
+```nginx
+location / {
+    proxy_pass http://backend;
+
+    # What errors trigger failover to next upstream
+    proxy_next_upstream error timeout http_502 http_503 http_504;
+
+    # Limit retries across upstream servers
+    proxy_next_upstream_tries 3;
+
+    # Limit total time for all retries
+    proxy_next_upstream_timeout 10s;
+}
+```
+
+### Active Health Checks (Nginx Plus or Third-Party)
+
+For open-source Nginx, use the `nginx_upstream_check_module` (third-party) or external health check tools.
+
+```nginx
+# Nginx Plus active health check
+upstream backend {
+    zone backend_zone 64k;    # Required for active checks
+
+    server 127.0.0.1:3000;
+    server 127.0.0.1:3001;
+}
+
+server {
+    location / {
+        proxy_pass http://backend;
+        health_check interval=5s fails=3 passes=2 uri=/health;
+    }
+}
+```
+
+### Application Health Endpoint Pattern
+
+```nginx
+# Backend should implement /health returning 200
+location /health {
+    proxy_pass http://backend;
+    access_log off;              # Don't clutter logs
+    proxy_connect_timeout 2s;    # Fail fast
+    proxy_read_timeout 2s;
+}
+```
+
+---
+
+## Proxy Headers
+
+### Essential Headers
+
+Every reverse proxy should forward these headers so the backend knows about the original request.
+
+```nginx
+location / {
+    proxy_pass http://backend;
+
+    # Pass the original Host header
+    proxy_set_header Host $host;
+
+    # Client's real IP address
+    proxy_set_header X-Real-IP $remote_addr;
+
+    # Append to existing forwarded-for chain
+    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+
+    # Original protocol (http or https)
+    proxy_set_header X-Forwarded-Proto $scheme;
+
+    # Original port
+    proxy_set_header X-Forwarded-Port $server_port;
+}
+```
+
+### Header Reference
+
+| Header | Variable | Purpose |
+|--------|----------|---------|
+| `Host` | `$host` | Original hostname from client request |
+| `X-Real-IP` | `$remote_addr` | Client's IP address |
+| `X-Forwarded-For` | `$proxy_add_x_forwarded_for` | Chain of proxy IPs |
+| `X-Forwarded-Proto` | `$scheme` | Original protocol (http/https) |
+| `X-Forwarded-Port` | `$server_port` | Original port number |
+| `X-Request-ID` | `$request_id` | Unique request identifier for tracing |
+| `Connection` | `""` | Clear hop-by-hop header for keepalive |
+
+### Reusable Headers Include
+
+Create a shared include file to avoid repetition:
+
+```nginx
+# /etc/nginx/includes/proxy-headers.conf
+proxy_set_header Host $host;
+proxy_set_header X-Real-IP $remote_addr;
+proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+proxy_set_header X-Forwarded-Proto $scheme;
+proxy_set_header X-Forwarded-Port $server_port;
+proxy_set_header X-Request-ID $request_id;
+```
+
+```nginx
+# Usage in server/location blocks
+location / {
+    proxy_pass http://backend;
+    include /etc/nginx/includes/proxy-headers.conf;
+}
+```
+
+### Hiding Backend Headers
+
+```nginx
+location / {
+    proxy_pass http://backend;
+
+    # Remove headers that leak backend info
+    proxy_hide_header X-Powered-By;
+    proxy_hide_header Server;
+    proxy_hide_header X-AspNet-Version;
+
+    # Pass through headers that are hidden by default
+    proxy_pass_header X-Custom-Header;
+}
+```
+
+---
+
+## WebSocket Proxy
+
+WebSocket requires HTTP/1.1 with the Upgrade mechanism.
+
+### Basic WebSocket Proxy
+
+```nginx
+# Map to handle Upgrade header
+map $http_upgrade $connection_upgrade {
+    default upgrade;
+    ''      close;
+}
+
+server {
+    listen 80;
+    server_name ws.example.com;
+
+    location /ws/ {
+        proxy_pass http://127.0.0.1:3000;
+        proxy_http_version 1.1;
+
+        # WebSocket upgrade headers
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection $connection_upgrade;
+
+        # Standard proxy headers
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+
+        # Long timeouts for persistent connections
+        proxy_read_timeout 3600s;
+        proxy_send_timeout 3600s;
+    }
+}
+```
+
+### WebSocket with SSL
+
+```nginx
+server {
+    listen 443 ssl http2;
+    server_name ws.example.com;
+
+    ssl_certificate     /etc/nginx/certs/fullchain.pem;
+    ssl_certificate_key /etc/nginx/certs/privkey.pem;
+
+    location /ws/ {
+        proxy_pass http://127.0.0.1:3000;
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection $connection_upgrade;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_read_timeout 3600s;
+        proxy_send_timeout 3600s;
+    }
+}
+```
+
+### Socket.IO Configuration
+
+Socket.IO uses both WebSocket and HTTP long-polling:
+
+```nginx
+location /socket.io/ {
+    proxy_pass http://127.0.0.1:3000;
+    proxy_http_version 1.1;
+    proxy_set_header Upgrade $http_upgrade;
+    proxy_set_header Connection $connection_upgrade;
+    proxy_set_header Host $host;
+    proxy_set_header X-Real-IP $remote_addr;
+    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+
+    # Important for Socket.IO long-polling fallback
+    proxy_buffering off;
+    proxy_cache off;
+}
+```
+
+---
+
+## gRPC Proxy
+
+### Basic gRPC Proxy
+
+```nginx
+upstream grpc_backend {
+    server 127.0.0.1:50051;
+}
+
+server {
+    listen 443 ssl http2;
+    server_name grpc.example.com;
+
+    ssl_certificate     /etc/nginx/certs/fullchain.pem;
+    ssl_certificate_key /etc/nginx/certs/privkey.pem;
+
+    location / {
+        # Use grpc_pass for gRPC backends
+        grpc_pass grpc://grpc_backend;
+
+        # gRPC-specific headers
+        grpc_set_header X-Real-IP $remote_addr;
+        grpc_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+    }
+}
+```
+
+### gRPC with TLS to Backend
+
+```nginx
+location / {
+    # grpcs:// for TLS-encrypted gRPC backends
+    grpc_pass grpcs://grpc_backend;
+
+    grpc_ssl_certificate     /etc/nginx/certs/client.pem;
+    grpc_ssl_certificate_key /etc/nginx/certs/client.key;
+    grpc_ssl_verify on;
+    grpc_ssl_trusted_certificate /etc/nginx/certs/ca.pem;
+}
+```
+
+### gRPC Error Handling
+
+```nginx
+location / {
+    grpc_pass grpc://grpc_backend;
+
+    # Intercept gRPC errors and return custom responses
+    grpc_intercept_errors on;
+
+    error_page 502 = /error502grpc;
+}
+
+location = /error502grpc {
+    internal;
+    default_type application/grpc;
+    add_header grpc-status 14;
+    add_header grpc-message "Backend unavailable";
+    return 204;
+}
+```
+
+---
+
+## Proxy Caching
+
+### Basic Cache Configuration
+
+```nginx
+http {
+    # Define cache storage
+    # levels=1:2       - Two-level directory hierarchy
+    # keys_zone=cache:10m - 10MB shared memory for cache keys (~80,000 keys)
+    # max_size=10g     - Maximum cache size on disk
+    # inactive=60m     - Remove items not accessed in 60 minutes
+    # use_temp_path=off - Write directly to cache dir (better performance)
+    proxy_cache_path /var/cache/nginx
+        levels=1:2
+        keys_zone=app_cache:10m
+        max_size=10g
+        inactive=60m
+        use_temp_path=off;
+
+    server {
+        listen 80;
+
+        location / {
+            proxy_pass http://backend;
+
+            # Enable caching with the named zone
+            proxy_cache app_cache;
+
+            # Cache different status codes for different durations
+            proxy_cache_valid 200 302 10m;
+            proxy_cache_valid 404     1m;
+            proxy_cache_valid any     5m;
+
+            # Custom cache key
+            proxy_cache_key "$scheme$request_method$host$request_uri";
+
+            # Add header to show cache status (HIT, MISS, BYPASS, etc.)
+            add_header X-Cache-Status $upstream_cache_status;
+        }
+    }
+}
+```
+
+### Cache Bypass
+
+```nginx
+location / {
+    proxy_pass http://backend;
+    proxy_cache app_cache;
+    proxy_cache_valid 200 10m;
+
+    # Bypass cache when specific conditions are met
+    proxy_cache_bypass $http_cache_control;   # Client sends Cache-Control
+    proxy_cache_bypass $cookie_nocache;       # Cookie "nocache" is set
+    proxy_cache_bypass $arg_nocache;          # Query param ?nocache=1
+
+    # Don't store in cache under these conditions
+    proxy_no_cache $http_pragma;              # Client sends Pragma: no-cache
+    proxy_no_cache $arg_nocache;
+}
+```
+
+### Stale Cache (Serve Old Content During Errors)
+
+```nginx
+location / {
+    proxy_pass http://backend;
+    proxy_cache app_cache;
+    proxy_cache_valid 200 10m;
+
+    # Serve stale content when backend is down or slow
+    proxy_cache_use_stale error timeout updating
+                          http_500 http_502 http_503 http_504;
+
+    # Update cache in background while serving stale
+    proxy_cache_background_update on;
+
+    # Only one request refreshes cache, others get stale
+    proxy_cache_lock on;
+    proxy_cache_lock_timeout 5s;
+}
+```
+
+### Cache Purge
+
+```nginx
+# Requires ngx_cache_purge module
+location ~ /purge(/.*) {
+    allow 127.0.0.1;
+    deny all;
+    proxy_cache_purge app_cache "$scheme$request_method$host$1";
+}
+```
+
+Usage: `curl -X PURGE https://example.com/purge/api/data`
+
+---
+
+## Proxy Buffering
+
+### Buffering On (Default)
+
+Nginx reads the entire response from the backend, then sends it to the client. Good for fast backends with slow clients.
+
+```nginx
+location / {
+    proxy_pass http://backend;
+
+    # Buffering on (default)
+    proxy_buffering on;
+
+    # Size of the buffer for the first part of the response (headers)
+    proxy_buffer_size 8k;
+
+    # Number and size of buffers for the response body
+    proxy_buffers 8 16k;
+
+    # Maximum size that can be busy sending to client
+    proxy_busy_buffers_size 32k;
+
+    # Temporary files if response exceeds buffers
+    proxy_temp_file_write_size 64k;
+    proxy_max_temp_file_size 1024m;
+}
+```
+
+### Buffering Off
+
+Send data to client as soon as it arrives from backend. Required for streaming.
+
+```nginx
+location /stream/ {
+    proxy_pass http://backend;
+
+    # Disable buffering for streaming responses
+    proxy_buffering off;
+
+    # Also disable request body buffering
+    proxy_request_buffering off;
+}
+```
+
+**Disable buffering for:**
+- Server-Sent Events (SSE)
+- Long-polling
+- Streaming downloads
+- Real-time data feeds
+- Large file downloads where you want immediate start
+
+### Server-Sent Events (SSE) Configuration
+
+```nginx
+location /events/ {
+    proxy_pass http://backend;
+    proxy_http_version 1.1;
+
+    # Critical for SSE
+    proxy_buffering off;
+    proxy_cache off;
+
+    # Don't add compression (breaks streaming)
+    proxy_set_header Accept-Encoding "";
+
+    # Keep connection alive
+    proxy_set_header Connection "";
+    proxy_read_timeout 86400s;
+
+    # Chunked transfer
+    chunked_transfer_encoding on;
+}
+```
+
+---
+
+## Keepalive Connections
+
+Reuse connections to upstream servers instead of opening a new TCP connection per request.
+
+### Upstream Keepalive
+
+```nginx
+upstream backend {
+    server 127.0.0.1:3000;
+    server 127.0.0.1:3001;
+
+    # Keep up to 32 idle connections alive per worker process
+    keepalive 32;
+
+    # Maximum requests per keepalive connection before closing
+    keepalive_requests 1000;
+
+    # Idle timeout for keepalive connections
+    keepalive_timeout 60s;
+}
+
+server {
+    location / {
+        proxy_pass http://backend;
+
+        # Required for keepalive to work with upstream
+        proxy_http_version 1.1;
+        proxy_set_header Connection "";
+
+        # Standard headers
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+    }
+}
+```
+
+**Important:** `proxy_http_version 1.1` and `proxy_set_header Connection ""` are both required. HTTP/1.0 uses `Connection: close` by default, which prevents keepalive.
+
+### Keepalive Sizing
+
+| Scenario | `keepalive` Value | Rationale |
+|----------|-------------------|-----------|
+| Low traffic | 8-16 | Minimal idle connections |
+| Medium traffic | 32-64 | Balance memory vs connection reuse |
+| High traffic | 128-256 | Maximize connection reuse |
+| Microservices | 16-32 per upstream | Per-service pools |
+
+---
+
+## Timeout Configuration
+
+### Complete Timeout Reference
+
+```nginx
+location / {
+    proxy_pass http://backend;
+
+    # Time to establish connection to backend
+    proxy_connect_timeout 5s;    # Default: 60s
+
+    # Time to wait for backend to start sending response
+    proxy_read_timeout 60s;      # Default: 60s
+
+    # Time allowed to send request body to backend
+    proxy_send_timeout 60s;      # Default: 60s
+}
+```
+
+### Timeout Guidelines
+
+| Timeout | Typical Value | Use Case |
+|---------|---------------|----------|
+| `proxy_connect_timeout` | 3-5s | Fail fast if backend is unreachable |
+| `proxy_read_timeout` | 30-60s | API responses, page rendering |
+| `proxy_read_timeout` | 300s+ | File uploads, long reports |
+| `proxy_read_timeout` | 3600s | WebSocket, SSE |
+| `proxy_send_timeout` | 30-60s | Most applications |
+
+### Per-Location Timeouts
+
+```nginx
+# Fast API endpoint
+location /api/ {
+    proxy_pass http://backend;
+    proxy_connect_timeout 3s;
+    proxy_read_timeout 10s;
+}
+
+# File upload endpoint
+location /upload/ {
+    proxy_pass http://backend;
+    proxy_connect_timeout 5s;
+    proxy_read_timeout 300s;
+    client_max_body_size 100m;
+}
+
+# Report generation (slow)
+location /reports/ {
+    proxy_pass http://backend;
+    proxy_connect_timeout 5s;
+    proxy_read_timeout 600s;
+}
+```
+
+---
+
+## Real-World Configurations
+
+### Node.js Application
+
+```nginx
+upstream nodejs {
+    server 127.0.0.1:3000;
+    keepalive 32;
+}
+
+server {
+    listen 443 ssl http2;
+    server_name app.example.com;
+
+    ssl_certificate     /etc/letsencrypt/live/app.example.com/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
+    include /etc/nginx/includes/ssl-params.conf;
+
+    # Security headers
+    add_header X-Content-Type-Options nosniff always;
+    add_header X-Frame-Options DENY always;
+    add_header Referrer-Policy strict-origin-when-cross-origin always;
+
+    # Proxy to Node.js
+    location / {
+        proxy_pass http://nodejs;
+        proxy_http_version 1.1;
+        proxy_set_header Connection "";
+
+        include /etc/nginx/includes/proxy-headers.conf;
+
+        proxy_connect_timeout 5s;
+        proxy_read_timeout 60s;
+
+        # Handle large JWT tokens
+        proxy_buffer_size 16k;
+        proxy_buffers 4 32k;
+    }
+
+    # Serve static files directly (bypass Node.js)
+    location /static/ {
+        alias /var/www/app/public/;
+        expires 30d;
+        add_header Cache-Control "public, immutable";
+        access_log off;
+    }
+
+    # WebSocket endpoint
+    location /ws {
+        proxy_pass http://nodejs;
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection $connection_upgrade;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_read_timeout 3600s;
+    }
+}
+```
+
+### Python/Gunicorn Application
+
+```nginx
+upstream gunicorn {
+    server unix:/run/gunicorn/app.sock fail_timeout=10s;
+    keepalive 16;
+}
+
+server {
+    listen 443 ssl http2;
+    server_name api.example.com;
+
+    ssl_certificate     /etc/letsencrypt/live/api.example.com/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
+    include /etc/nginx/includes/ssl-params.conf;
+
+    client_max_body_size 10m;
+
+    location / {
+        proxy_pass http://gunicorn;
+        proxy_http_version 1.1;
+        proxy_set_header Connection "";
+
+        include /etc/nginx/includes/proxy-headers.conf;
+
+        proxy_connect_timeout 5s;
+        proxy_read_timeout 30s;
+
+        proxy_buffer_size 8k;
+        proxy_buffers 4 16k;
+    }
+
+    # Django static files
+    location /static/ {
+        alias /var/www/app/staticfiles/;
+        expires 30d;
+        access_log off;
+    }
+
+    # Django media uploads
+    location /media/ {
+        alias /var/www/app/media/;
+        expires 7d;
+    }
+}
+```
+
+### Go Binary Application
+
+```nginx
+upstream goapp {
+    server 127.0.0.1:8080;
+    server 127.0.0.1:8081;
+    keepalive 64;
+}
+
+server {
+    listen 443 ssl http2;
+    server_name service.example.com;
+
+    ssl_certificate     /etc/letsencrypt/live/service.example.com/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/service.example.com/privkey.pem;
+    include /etc/nginx/includes/ssl-params.conf;
+
+    # Go apps typically serve their own static files
+    location / {
+        proxy_pass http://goapp;
+        proxy_http_version 1.1;
+        proxy_set_header Connection "";
+
+        include /etc/nginx/includes/proxy-headers.conf;
+
+        proxy_connect_timeout 3s;
+        proxy_read_timeout 30s;
+
+        # Go apps handle large payloads efficiently
+        proxy_request_buffering off;
+    }
+
+    # Health check
+    location /healthz {
+        proxy_pass http://goapp;
+        access_log off;
+        proxy_connect_timeout 2s;
+        proxy_read_timeout 2s;
+    }
+}
+```
+
+### PHP-FPM Application
+
+```nginx
+upstream php-fpm {
+    server unix:/run/php/php8.3-fpm.sock;
+}
+
+server {
+    listen 443 ssl http2;
+    server_name site.example.com;
+
+    ssl_certificate     /etc/letsencrypt/live/site.example.com/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/site.example.com/privkey.pem;
+    include /etc/nginx/includes/ssl-params.conf;
+
+    root /var/www/site/public;
+    index index.php index.html;
+
+    client_max_body_size 50m;
+
+    # Try static file first, then directory, then PHP
+    location / {
+        try_files $uri $uri/ /index.php?$query_string;
+    }
+
+    # PHP processing
+    location ~ \.php$ {
+        fastcgi_pass php-fpm;
+        fastcgi_index index.php;
+        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
+        include fastcgi_params;
+
+        fastcgi_connect_timeout 5s;
+        fastcgi_send_timeout 30s;
+        fastcgi_read_timeout 30s;
+
+        fastcgi_buffer_size 16k;
+        fastcgi_buffers 4 16k;
+    }
+
+    # Deny access to hidden files (except .well-known)
+    location ~ /\.(?!well-known) {
+        deny all;
+    }
+
+    # Static assets
+    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
+        expires 30d;
+        add_header Cache-Control "public, immutable";
+        access_log off;
+    }
+}
+```
+
+### Multiple Services on One Domain (Path-Based Routing)
+
+```nginx
+# Upstream definitions for each service
+upstream api_service {
+    server 127.0.0.1:3000;
+    keepalive 32;
+}
+
+upstream admin_service {
+    server 127.0.0.1:4000;
+    keepalive 16;
+}
+
+upstream docs_service {
+    server 127.0.0.1:5000;
+    keepalive 8;
+}
+
+server {
+    listen 443 ssl http2;
+    server_name example.com;
+
+    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
+    include /etc/nginx/includes/ssl-params.conf;
+
+    # API service at /api/
+    location /api/ {
+        proxy_pass http://api_service/;    # Trailing / strips /api/ prefix
+        proxy_http_version 1.1;
+        proxy_set_header Connection "";
+        include /etc/nginx/includes/proxy-headers.conf;
+
+        # API-specific settings
+        proxy_read_timeout 30s;
+        client_max_body_size 10m;
+
+        # Rate limiting for API
+        limit_req zone=api burst=20 nodelay;
+    }
+
+    # Admin panel at /admin/
+    location /admin/ {
+        proxy_pass http://admin_service/;
+        proxy_http_version 1.1;
+        proxy_set_header Connection "";
+        include /etc/nginx/includes/proxy-headers.conf;
+
+        # Restrict admin access by IP
+        allow 10.0.0.0/8;
+        allow 192.168.0.0/16;
+        deny all;
+    }
+
+    # Documentation at /docs/
+    location /docs/ {
+        proxy_pass http://docs_service/;
+        proxy_http_version 1.1;
+        proxy_set_header Connection "";
+        include /etc/nginx/includes/proxy-headers.conf;
+
+        # Cache documentation pages
+        proxy_cache app_cache;
+        proxy_cache_valid 200 1h;
+    }
+
+    # Frontend SPA (catch-all)
+    location / {
+        root /var/www/frontend/dist;
+        index index.html;
+        try_files $uri $uri/ /index.html;
+    }
+}
+```
+
+### Proxy to Multiple Ports with Subdomain Routing
+
+```nginx
+# Alternative: subdomain-based routing
+server {
+    listen 443 ssl http2;
+    server_name api.example.com;
+    include /etc/nginx/includes/ssl-params.conf;
+
+    location / {
+        proxy_pass http://api_service;
+        include /etc/nginx/includes/proxy-headers.conf;
+    }
+}
+
+server {
+    listen 443 ssl http2;
+    server_name admin.example.com;
+    include /etc/nginx/includes/ssl-params.conf;
+
+    location / {
+        proxy_pass http://admin_service;
+        include /etc/nginx/includes/proxy-headers.conf;
+    }
+}
+```

+ 998 - 0
skills/nginx-ops/references/ssl-security.md

@@ -0,0 +1,998 @@
+# SSL/TLS & Security Reference
+
+Comprehensive guide to Nginx SSL/TLS configuration, Let's Encrypt automation, security headers, rate limiting, access control, and mutual TLS.
+
+---
+
+## Table of Contents
+
+1. [TLS Configuration](#tls-configuration)
+2. [Let's Encrypt & Certbot](#lets-encrypt--certbot)
+3. [Certificate Management](#certificate-management)
+4. [HSTS](#hsts)
+5. [OCSP Stapling](#ocsp-stapling)
+6. [Security Headers](#security-headers)
+7. [Rate Limiting](#rate-limiting)
+8. [IP Restrictions](#ip-restrictions)
+9. [Basic Authentication](#basic-authentication)
+10. [Mutual TLS (mTLS)](#mutual-tls-mtls)
+11. [HTTP to HTTPS Redirect](#http-to-https-redirect)
+
+---
+
+## TLS Configuration
+
+### Modern Configuration (TLS 1.3 Only)
+
+For services where all clients support TLS 1.3 (modern browsers, API clients you control).
+
+```nginx
+server {
+    listen 443 ssl http2;
+    server_name example.com;
+
+    ssl_certificate     /etc/nginx/certs/fullchain.pem;
+    ssl_certificate_key /etc/nginx/certs/privkey.pem;
+
+    # TLS 1.3 only
+    ssl_protocols TLSv1.3;
+
+    # TLS 1.3 ciphers are not configurable via ssl_ciphers
+    # They are negotiated automatically:
+    # TLS_AES_256_GCM_SHA384
+    # TLS_CHACHA20_POLY1305_SHA256
+    # TLS_AES_128_GCM_SHA256
+
+    ssl_prefer_server_ciphers off;
+}
+```
+
+### Intermediate Configuration (TLS 1.2 + 1.3)
+
+Recommended for most production sites. Compatible with all modern browsers.
+
+```nginx
+server {
+    listen 443 ssl http2;
+    server_name example.com;
+
+    ssl_certificate     /etc/nginx/certs/fullchain.pem;
+    ssl_certificate_key /etc/nginx/certs/privkey.pem;
+
+    # TLS 1.2 and 1.3
+    ssl_protocols TLSv1.2 TLSv1.3;
+
+    # Cipher suite for TLS 1.2 (TLS 1.3 ciphers are automatic)
+    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
+    ssl_prefer_server_ciphers off;
+
+    # DH parameters for DHE ciphers
+    ssl_dhparam /etc/nginx/dhparam.pem;
+
+    # Session caching
+    ssl_session_cache shared:SSL:10m;
+    ssl_session_timeout 1d;
+    ssl_session_tickets off;
+}
+```
+
+### Generate DH Parameters
+
+```bash
+# Generate 4096-bit DH parameters (takes several minutes)
+openssl dhparam -out /etc/nginx/dhparam.pem 4096
+
+# Or use pre-generated params from Mozilla (faster, still secure)
+curl -sL https://ssl-config.mozilla.org/ffdhe2048.txt > /etc/nginx/dhparam.pem
+```
+
+### SSL Session Configuration
+
+```nginx
+# Shared session cache across all worker processes
+# 10m = 10MB, enough for ~40,000 sessions
+ssl_session_cache shared:SSL:10m;
+
+# Session lifetime
+ssl_session_timeout 1d;
+
+# Disable session tickets (better forward secrecy)
+# Enable only if you rotate ticket keys regularly
+ssl_session_tickets off;
+```
+
+### TLS Version Comparison
+
+| Version | Status | Performance | Security | Support |
+|---------|--------|-------------|----------|---------|
+| TLS 1.0 | Deprecated | Slow | Weak | Drop immediately |
+| TLS 1.1 | Deprecated | Slow | Weak | Drop immediately |
+| TLS 1.2 | Active | Good | Strong | All modern browsers |
+| TLS 1.3 | Preferred | Best (0-RTT) | Strongest | 95%+ browsers |
+
+---
+
+## Let's Encrypt & Certbot
+
+### Installation
+
+```bash
+# Debian/Ubuntu
+sudo apt update
+sudo apt install certbot python3-certbot-nginx
+
+# RHEL/Fedora
+sudo dnf install certbot python3-certbot-nginx
+
+# Alpine
+sudo apk add certbot certbot-nginx
+
+# Snap (universal)
+sudo snap install --classic certbot
+sudo ln -s /snap/bin/certbot /usr/bin/certbot
+```
+
+### Obtaining Certificates
+
+#### Nginx Plugin (Easiest)
+
+Certbot automatically modifies your nginx config.
+
+```bash
+# Single domain
+sudo certbot --nginx -d example.com
+
+# Multiple domains
+sudo certbot --nginx -d example.com -d www.example.com -d api.example.com
+
+# Non-interactive (for automation)
+sudo certbot --nginx --non-interactive --agree-tos \
+    --email admin@example.com -d example.com
+```
+
+#### Webroot Method (No Restart)
+
+Use when you don't want certbot to modify your nginx config.
+
+```nginx
+# Add this to your nginx server block first
+location /.well-known/acme-challenge/ {
+    root /var/www/certbot;
+}
+```
+
+```bash
+sudo certbot certonly --webroot -w /var/www/certbot -d example.com
+```
+
+#### Standalone Method
+
+Certbot runs its own temporary web server (requires port 80 to be free).
+
+```bash
+# Stop nginx first
+sudo systemctl stop nginx
+
+sudo certbot certonly --standalone -d example.com
+
+# Restart nginx
+sudo systemctl start nginx
+```
+
+#### DNS Challenge (Wildcard Certificates)
+
+Required for wildcard certificates (`*.example.com`).
+
+```bash
+# Manual DNS challenge
+sudo certbot certonly --manual --preferred-challenges dns -d "*.example.com"
+
+# With DNS plugin (Cloudflare example)
+sudo certbot certonly --dns-cloudflare \
+    --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
+    -d example.com -d "*.example.com"
+```
+
+Cloudflare credentials file:
+
+```ini
+# /etc/letsencrypt/cloudflare.ini
+dns_cloudflare_api_token = your-api-token-here
+```
+
+```bash
+# Secure the credentials file
+sudo chmod 600 /etc/letsencrypt/cloudflare.ini
+```
+
+### Auto-Renewal
+
+#### Systemd Timer (Recommended)
+
+Certbot usually installs this automatically.
+
+```ini
+# /etc/systemd/system/certbot.timer
+[Unit]
+Description=Run certbot twice daily
+
+[Timer]
+OnCalendar=*-*-* 00,12:00:00
+RandomizedDelaySec=3600
+Persistent=true
+
+[Install]
+WantedBy=timers.target
+```
+
+```ini
+# /etc/systemd/system/certbot.service
+[Unit]
+Description=Certbot renewal
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/certbot renew --quiet
+```
+
+```bash
+# Enable and start
+sudo systemctl enable certbot.timer
+sudo systemctl start certbot.timer
+
+# Check status
+sudo systemctl list-timers certbot.timer
+```
+
+#### Cron Alternative
+
+```bash
+# /etc/cron.d/certbot
+0 0,12 * * * root certbot renew --quiet --deploy-hook "systemctl reload nginx"
+```
+
+### Renewal Hooks
+
+```bash
+# Test renewal with hooks
+sudo certbot renew --dry-run \
+    --pre-hook "echo 'Before renewal'" \
+    --post-hook "systemctl reload nginx" \
+    --deploy-hook "echo 'Certificate renewed'"
+
+# Hook scripts (placed in /etc/letsencrypt/renewal-hooks/)
+# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
+#!/bin/bash
+systemctl reload nginx
+```
+
+```bash
+chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
+```
+
+### Certificate File Locations
+
+```
+/etc/letsencrypt/live/example.com/
+├── cert.pem          # Domain certificate only
+├── chain.pem         # Intermediate CA certificate(s)
+├── fullchain.pem     # cert.pem + chain.pem (use this for ssl_certificate)
+├── privkey.pem       # Private key (use this for ssl_certificate_key)
+└── README
+```
+
+---
+
+## Certificate Management
+
+### Certificate Chain Configuration
+
+```nginx
+# fullchain.pem includes: domain cert + intermediate CA cert(s)
+ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
+ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
+
+# Trusted certificate for OCSP stapling verification
+ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
+```
+
+### Verify Certificate Chain
+
+```bash
+# Check certificate details
+openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -text -noout
+
+# Verify chain
+openssl verify -CAfile /etc/letsencrypt/live/example.com/chain.pem \
+    /etc/letsencrypt/live/example.com/cert.pem
+
+# Check expiration
+openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -enddate
+
+# Test SSL from outside
+openssl s_client -connect example.com:443 -servername example.com
+```
+
+### Multiple Certificates (RSA + ECDSA)
+
+Serve different certificate types for maximum compatibility and performance.
+
+```nginx
+server {
+    listen 443 ssl http2;
+    server_name example.com;
+
+    # RSA certificate (compatibility)
+    ssl_certificate     /etc/nginx/certs/example.com-rsa.pem;
+    ssl_certificate_key /etc/nginx/certs/example.com-rsa.key;
+
+    # ECDSA certificate (performance) - Nginx picks the best one
+    ssl_certificate     /etc/nginx/certs/example.com-ecdsa.pem;
+    ssl_certificate_key /etc/nginx/certs/example.com-ecdsa.key;
+}
+```
+
+---
+
+## HSTS
+
+HTTP Strict Transport Security tells browsers to always use HTTPS for this domain.
+
+### Basic HSTS
+
+```nginx
+# 2-year max-age (recommended for production)
+add_header Strict-Transport-Security "max-age=63072000" always;
+```
+
+### HSTS with Subdomains
+
+```nginx
+# Apply to all subdomains as well
+add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
+```
+
+### HSTS Preload
+
+Submit to browser preload list (permanently enforced, difficult to undo).
+
+```nginx
+add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
+```
+
+**Before enabling preload:**
+1. Ensure ALL subdomains support HTTPS
+2. Start with a short `max-age` (e.g., 300) and test
+3. Submit at https://hstspreload.org/
+
+### Gradual HSTS Rollout
+
+```nginx
+# Step 1: Short max-age, monitor for issues (1 week)
+add_header Strict-Transport-Security "max-age=604800" always;
+
+# Step 2: Increase to 1 month
+add_header Strict-Transport-Security "max-age=2592000" always;
+
+# Step 3: Include subdomains
+add_header Strict-Transport-Security "max-age=2592000; includeSubDomains" always;
+
+# Step 4: Full production (2 years + preload)
+add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
+```
+
+---
+
+## OCSP Stapling
+
+OCSP stapling embeds the certificate's revocation status in the TLS handshake, improving connection speed and privacy.
+
+```nginx
+server {
+    listen 443 ssl http2;
+    server_name example.com;
+
+    # Enable OCSP stapling
+    ssl_stapling on;
+
+    # Verify OCSP response using trusted CA cert
+    ssl_stapling_verify on;
+
+    # CA cert chain for verification (intermediate + root)
+    ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
+
+    # DNS resolver for OCSP responder lookup
+    resolver 1.1.1.1 8.8.8.8 valid=300s;
+    resolver_timeout 5s;
+}
+```
+
+### Verify OCSP Stapling
+
+```bash
+# Test OCSP stapling
+openssl s_client -connect example.com:443 -servername example.com -status 2>/dev/null | \
+    grep -A 17 "OCSP Response Status"
+
+# Should show: "OCSP Response Status: successful (0x0)"
+```
+
+---
+
+## Security Headers
+
+### Complete Security Headers Configuration
+
+```nginx
+# /etc/nginx/includes/security-headers.conf
+
+# Prevent MIME type sniffing
+add_header X-Content-Type-Options nosniff always;
+
+# Clickjacking protection
+add_header X-Frame-Options DENY always;
+# Or allow same-origin framing:
+# add_header X-Frame-Options SAMEORIGIN always;
+
+# XSS Protection (legacy browsers)
+add_header X-XSS-Protection "1; mode=block" always;
+
+# Referrer Policy
+add_header Referrer-Policy strict-origin-when-cross-origin always;
+
+# Permissions Policy (formerly Feature-Policy)
+add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
+
+# Content Security Policy
+add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
+
+# Cross-Origin policies
+add_header Cross-Origin-Opener-Policy same-origin always;
+add_header Cross-Origin-Resource-Policy same-origin always;
+add_header Cross-Origin-Embedder-Policy require-corp always;
+```
+
+### Usage
+
+```nginx
+server {
+    listen 443 ssl http2;
+    server_name example.com;
+
+    include /etc/nginx/includes/security-headers.conf;
+
+    # ... rest of config
+}
+```
+
+### Header Reference
+
+| Header | Value | Purpose |
+|--------|-------|---------|
+| `X-Content-Type-Options` | `nosniff` | Prevent MIME type sniffing |
+| `X-Frame-Options` | `DENY` or `SAMEORIGIN` | Prevent clickjacking |
+| `X-XSS-Protection` | `1; mode=block` | Legacy XSS filter |
+| `Referrer-Policy` | `strict-origin-when-cross-origin` | Control referrer leakage |
+| `Permissions-Policy` | `camera=(), ...` | Disable browser features |
+| `Content-Security-Policy` | `default-src 'self'; ...` | Control resource loading |
+| `Strict-Transport-Security` | `max-age=63072000; ...` | Force HTTPS |
+| `Cross-Origin-Opener-Policy` | `same-origin` | Isolate browsing context |
+| `Cross-Origin-Resource-Policy` | `same-origin` | Prevent cross-origin reads |
+
+### Content-Security-Policy Examples
+
+```nginx
+# Minimal CSP (strict)
+add_header Content-Security-Policy "default-src 'self';" always;
+
+# With Google Fonts and Analytics
+add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://www.google-analytics.com;" always;
+
+# API-only (no HTML rendering)
+add_header Content-Security-Policy "default-src 'none'; frame-ancestors 'none';" always;
+
+# Report-only mode (for testing)
+add_header Content-Security-Policy-Report-Only "default-src 'self'; report-uri /csp-report;" always;
+```
+
+---
+
+## Rate Limiting
+
+### Basic Rate Limiting
+
+```nginx
+http {
+    # Define rate limit zone
+    # $binary_remote_addr = client IP (compact binary, 4 or 16 bytes)
+    # zone=name:size     = shared memory zone name and size
+    # rate=10r/s         = 10 requests per second
+    limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
+
+    server {
+        location / {
+            # Apply rate limit
+            # burst=20  = allow 20 excess requests to queue
+            # nodelay   = process burst immediately (don't throttle)
+            limit_req zone=general burst=20 nodelay;
+
+            # Custom status code (default is 503)
+            limit_req_status 429;
+
+            proxy_pass http://backend;
+        }
+    }
+}
+```
+
+### Multiple Rate Limit Zones
+
+```nginx
+http {
+    # Global rate limit: 30 req/s per IP
+    limit_req_zone $binary_remote_addr zone=global:10m rate=30r/s;
+
+    # Login rate limit: 5 req/min per IP
+    limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
+
+    # API rate limit: by API key
+    limit_req_zone $http_x_api_key zone=api:10m rate=100r/s;
+
+    server {
+        # Global limit applies everywhere
+        limit_req zone=global burst=50 nodelay;
+
+        location /api/login {
+            # Stricter limit for login endpoint
+            limit_req zone=login burst=3 nodelay;
+            proxy_pass http://backend;
+        }
+
+        location /api/ {
+            # API key-based limiting
+            limit_req zone=api burst=200 nodelay;
+            proxy_pass http://backend;
+        }
+    }
+}
+```
+
+### Connection Limiting
+
+Limit the number of simultaneous connections per IP.
+
+```nginx
+http {
+    # Define connection limit zone
+    limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
+
+    server {
+        # Max 20 simultaneous connections per IP
+        limit_conn conn_limit 20;
+
+        # Limit bandwidth per connection (useful for downloads)
+        limit_rate 1m;               # 1MB/s per connection
+        limit_rate_after 10m;        # Full speed for first 10MB
+
+        location /downloads/ {
+            # Tighter limits for download section
+            limit_conn conn_limit 5;
+            limit_rate 500k;
+        }
+    }
+}
+```
+
+### Rate Limiting with Whitelisting
+
+```nginx
+http {
+    # Map to identify whitelisted IPs
+    geo $rate_limit {
+        default         1;
+        10.0.0.0/8      0;    # Internal network
+        192.168.0.0/16  0;    # Private network
+        203.0.113.50    0;    # Monitoring server
+    }
+
+    # Only apply rate limiting to non-whitelisted IPs
+    map $rate_limit $rate_limit_key {
+        0 "";
+        1 $binary_remote_addr;
+    }
+
+    limit_req_zone $rate_limit_key zone=api:10m rate=10r/s;
+
+    server {
+        location /api/ {
+            limit_req zone=api burst=20 nodelay;
+            proxy_pass http://backend;
+        }
+    }
+}
+```
+
+### Logging Rate-Limited Requests
+
+```nginx
+http {
+    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
+
+    # Log rate-limited requests at warn level
+    limit_req_log_level warn;
+
+    # Custom log format for rate-limited requests
+    log_format ratelimit '$remote_addr - $remote_user [$time_local] '
+                         '"$request" $status $body_bytes_sent '
+                         '"limit_req_status=$limit_req_status"';
+}
+```
+
+---
+
+## IP Restrictions
+
+### Allow/Deny Directives
+
+```nginx
+location /admin/ {
+    # Allow specific IPs and ranges
+    allow 10.0.0.0/8;
+    allow 192.168.1.0/24;
+    allow 203.0.113.50;
+
+    # Deny everything else
+    deny all;
+
+    proxy_pass http://admin_backend;
+}
+```
+
+**Order matters:** Nginx evaluates `allow`/`deny` rules in order and uses the first match.
+
+### Geo Module
+
+Map client IP to a variable for conditional logic.
+
+```nginx
+http {
+    geo $allowed_country {
+        default         no;
+        10.0.0.0/8      yes;    # Internal
+        203.0.0.0/8     yes;    # Example allowed range
+    }
+
+    server {
+        location / {
+            if ($allowed_country = no) {
+                return 403;
+            }
+            proxy_pass http://backend;
+        }
+    }
+}
+```
+
+### GeoIP2 Module
+
+For geo-blocking or geo-routing by country. Requires `ngx_http_geoip2_module` and MaxMind GeoLite2 database.
+
+```nginx
+# Load GeoIP2 module
+load_module modules/ngx_http_geoip2_module.so;
+
+http {
+    geoip2 /usr/share/GeoIP/GeoLite2-Country.mmdb {
+        auto_reload 60m;
+        $geoip2_metadata_country_build metadata build_epoch;
+        $geoip2_data_country_code country iso_code;
+        $geoip2_data_country_name country names en;
+    }
+
+    # Block specific countries
+    map $geoip2_data_country_code $blocked_country {
+        default no;
+        XX      yes;    # Replace XX with country code
+        YY      yes;
+    }
+
+    server {
+        if ($blocked_country = yes) {
+            return 403;
+        }
+    }
+}
+```
+
+### Combining IP and Authentication
+
+```nginx
+location /admin/ {
+    # Require BOTH IP match AND authentication
+    satisfy all;
+
+    allow 10.0.0.0/8;
+    deny all;
+
+    auth_basic "Admin Area";
+    auth_basic_user_file /etc/nginx/.htpasswd;
+
+    proxy_pass http://admin_backend;
+}
+
+location /internal/ {
+    # Require EITHER IP match OR authentication
+    satisfy any;
+
+    allow 10.0.0.0/8;
+    deny all;
+
+    auth_basic "Internal Area";
+    auth_basic_user_file /etc/nginx/.htpasswd;
+
+    proxy_pass http://internal_backend;
+}
+```
+
+---
+
+## Basic Authentication
+
+### Setup
+
+```bash
+# Install htpasswd utility
+sudo apt install apache2-utils    # Debian/Ubuntu
+sudo dnf install httpd-tools      # RHEL/Fedora
+
+# Create password file with first user
+sudo htpasswd -c /etc/nginx/.htpasswd admin
+
+# Add additional users (no -c flag!)
+sudo htpasswd /etc/nginx/.htpasswd user2
+
+# Use bcrypt hashing (more secure, requires htpasswd 2.4+)
+sudo htpasswd -B /etc/nginx/.htpasswd user3
+
+# Secure the file
+sudo chown root:www-data /etc/nginx/.htpasswd
+sudo chmod 640 /etc/nginx/.htpasswd
+```
+
+### Nginx Configuration
+
+```nginx
+location /admin/ {
+    auth_basic "Admin Area";
+    auth_basic_user_file /etc/nginx/.htpasswd;
+
+    proxy_pass http://admin_backend;
+}
+
+# Disable auth for specific sub-paths
+location /admin/health {
+    auth_basic off;
+    proxy_pass http://admin_backend;
+}
+```
+
+### Auth for Entire Site with Exceptions
+
+```nginx
+server {
+    listen 443 ssl http2;
+    server_name staging.example.com;
+
+    # Global auth for staging environment
+    auth_basic "Staging Environment";
+    auth_basic_user_file /etc/nginx/.htpasswd;
+
+    location / {
+        proxy_pass http://backend;
+    }
+
+    # Exempt health checks and webhooks
+    location /health {
+        auth_basic off;
+        proxy_pass http://backend;
+    }
+
+    location /webhooks/ {
+        auth_basic off;
+        proxy_pass http://backend;
+    }
+}
+```
+
+---
+
+## Mutual TLS (mTLS)
+
+Mutual TLS requires both server and client to present certificates, providing strong authentication.
+
+### Server Configuration
+
+```nginx
+server {
+    listen 443 ssl http2;
+    server_name api.example.com;
+
+    # Server certificate (standard)
+    ssl_certificate     /etc/nginx/certs/server.pem;
+    ssl_certificate_key /etc/nginx/certs/server.key;
+
+    # CA certificate that signed client certificates
+    ssl_client_certificate /etc/nginx/certs/client-ca.pem;
+
+    # Require client certificate
+    ssl_verify_client on;
+    # Or make it optional:
+    # ssl_verify_client optional;
+
+    # Verification depth (how many intermediate CAs to check)
+    ssl_verify_depth 2;
+
+    # CRL for revoked client certificates
+    ssl_crl /etc/nginx/certs/client-revoked.crl;
+
+    location / {
+        # Pass client certificate info to backend
+        proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
+        proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
+        proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
+        proxy_set_header X-SSL-Client-Fingerprint $ssl_client_fingerprint;
+
+        proxy_pass http://backend;
+    }
+}
+```
+
+### Optional Client Certificate
+
+```nginx
+server {
+    listen 443 ssl http2;
+    server_name example.com;
+
+    ssl_client_certificate /etc/nginx/certs/client-ca.pem;
+    ssl_verify_client optional;
+
+    location /public/ {
+        # No client cert required
+        proxy_pass http://backend;
+    }
+
+    location /secure/ {
+        # Require valid client cert for this path
+        if ($ssl_client_verify != SUCCESS) {
+            return 403;
+        }
+        proxy_pass http://secure_backend;
+    }
+}
+```
+
+### Generate Client Certificates
+
+```bash
+# 1. Create CA (one-time)
+openssl genrsa -out client-ca.key 4096
+openssl req -new -x509 -days 3650 -key client-ca.key -out client-ca.pem \
+    -subj "/CN=Client CA"
+
+# 2. Generate client key and CSR
+openssl genrsa -out client.key 2048
+openssl req -new -key client.key -out client.csr \
+    -subj "/CN=client-name/O=organization"
+
+# 3. Sign with CA
+openssl x509 -req -days 365 -in client.csr -CA client-ca.pem \
+    -CAkey client-ca.key -CAcreateserial -out client.pem
+
+# 4. Create PKCS12 bundle for browser import
+openssl pkcs12 -export -out client.p12 \
+    -inkey client.key -in client.pem -certfile client-ca.pem
+
+# 5. Test with curl
+curl --cert client.pem --key client.key https://api.example.com/
+```
+
+### Client Certificate Variables
+
+| Variable | Description |
+|----------|-------------|
+| `$ssl_client_verify` | `SUCCESS`, `FAILED:reason`, or `NONE` |
+| `$ssl_client_s_dn` | Subject DN of client certificate |
+| `$ssl_client_i_dn` | Issuer DN of client certificate |
+| `$ssl_client_serial` | Serial number of client certificate |
+| `$ssl_client_fingerprint` | SHA1 fingerprint of client certificate |
+| `$ssl_client_cert` | PEM-encoded client certificate |
+| `$ssl_client_raw_cert` | PEM-encoded client certificate (unescaped) |
+| `$ssl_client_escaped_cert` | URL-encoded client certificate |
+
+---
+
+## HTTP to HTTPS Redirect
+
+### Standard Redirect
+
+```nginx
+# Redirect all HTTP to HTTPS
+server {
+    listen 80;
+    server_name example.com www.example.com;
+    return 301 https://example.com$request_uri;
+}
+```
+
+### Catch-All Redirect
+
+```nginx
+# Redirect ANY domain on HTTP to HTTPS
+server {
+    listen 80 default_server;
+    server_name _;
+    return 301 https://$host$request_uri;
+}
+```
+
+### Redirect with Let's Encrypt Exception
+
+```nginx
+server {
+    listen 80;
+    server_name example.com www.example.com;
+
+    # Allow ACME challenge for certificate renewal
+    location /.well-known/acme-challenge/ {
+        root /var/www/certbot;
+    }
+
+    # Redirect everything else to HTTPS
+    location / {
+        return 301 https://example.com$request_uri;
+    }
+}
+```
+
+### WWW to Non-WWW (with HTTPS)
+
+```nginx
+# Redirect www to non-www
+server {
+    listen 443 ssl http2;
+    server_name www.example.com;
+
+    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
+
+    return 301 https://example.com$request_uri;
+}
+
+# Redirect HTTP www to HTTPS non-www
+server {
+    listen 80;
+    server_name www.example.com;
+    return 301 https://example.com$request_uri;
+}
+```
+
+### Redirect with Preserved POST Body
+
+Note: `301` and `302` redirects convert POST to GET. Use `307`/`308` to preserve the method.
+
+```nginx
+# 308 Permanent Redirect (preserves HTTP method)
+server {
+    listen 80;
+    server_name api.example.com;
+    return 308 https://api.example.com$request_uri;
+}
+```
+
+| Status | Permanent | Preserves Method |
+|--------|-----------|-----------------|
+| 301 | Yes | No (POST → GET) |
+| 302 | No | No (POST → GET) |
+| 307 | No | Yes |
+| 308 | Yes | Yes |

+ 0 - 0
skills/nginx-ops/scripts/.gitkeep


+ 272 - 0
skills/react-ops/SKILL.md

@@ -0,0 +1,272 @@
+---
+name: react-ops
+description: "React development patterns, hooks, state management, Server Components, and performance optimization. Use for: react, hooks, useState, useEffect, jsx, tsx, next.js, nextjs, app router, server components, RSC, zustand, react query, component patterns, react testing library, error boundary, suspense, react 19."
+allowed-tools: "Read Write Bash"
+related-skills: [typescript-ops, testing-ops, tailwind-ops, javascript-ops]
+---
+
+# React Operations
+
+Comprehensive React skill covering hooks, component architecture, state management, Server Components, and performance optimization.
+
+## Hook Selection Decision Tree
+
+```
+What problem are you solving?
+│
+├─ Storing UI state that triggers re-renders
+│  ├─ Simple value (string, number, boolean)
+│  │  └─ useState
+│  ├─ Complex state with multiple sub-values and logic
+│  │  └─ useReducer (actions + reducer = predictable transitions)
+│  └─ Derived from existing state
+│     └─ Calculate inline or useMemo — not useState
+│
+├─ Referencing a value WITHOUT triggering re-render
+│  ├─ DOM element reference
+│  │  └─ useRef<HTMLElement>(null) + ref={ref}
+│  └─ Mutable value (timer ID, previous value, counter)
+│     └─ useRef (mutate ref.current directly)
+│
+├─ Running a side effect
+│  ├─ After every render (or specific deps)
+│  │  ├─ Needs cleanup (subscription, timer, abort)
+│  │  │  └─ useEffect with return cleanup function
+│  │  └─ No cleanup (logging, analytics)
+│  │     └─ useEffect with empty or dep array
+│  ├─ Before browser paint (DOM mutation, animation)
+│  │  └─ useLayoutEffect
+│  └─ Triggered by user action (not render)
+│     └─ Call it directly in the event handler — not useEffect
+│
+├─ Caching an expensive computation
+│  └─ useMemo(() => expensiveCalc(a, b), [a, b])
+│
+├─ Stable callback reference for child props / event handlers
+│  └─ useCallback(() => doThing(dep), [dep])
+│
+├─ Reading shared context value
+│  └─ useContext(MyContext)
+│
+├─ Generating stable unique ID (forms, aria)
+│  └─ useId()
+│
+├─ Syncing external store (Redux, Zustand internals)
+│  └─ useSyncExternalStore(subscribe, getSnapshot)
+│
+└─ React 19+
+   ├─ Await a promise or read context
+   │  └─ use(promise | context)
+   ├─ Form submit state (pending, data, action)
+   │  └─ useFormStatus / useActionState
+   └─ Optimistic UI before server response
+      └─ useOptimistic(state, updateFn)
+```
+
+## Component Pattern Decision Tree
+
+```
+What's your composition challenge?
+│
+├─ Group of related components sharing implicit state
+│  (Tabs, Accordion, Select, Menu)
+│  └─ Compound Components with Context
+│     Parent provides state via Context
+│     Children consume via useContext
+│
+├─ Consumer needs to control rendering output
+│  └─ Render Props: children(props) or render={fn}
+│     Good for: headless UI, flexible layouts
+│
+├─ Apply cross-cutting concerns (auth, logging, theming)
+│  to multiple components
+│  └─ Higher-Order Components (HOC)
+│     Wrap with withAuth(Component) or withLogging(Component)
+│     Prefer custom hooks for pure logic
+│
+├─ Encapsulate reusable stateful logic
+│  └─ Custom Hook — always prefer over HOC when possible
+│     Composable, testable, no wrapper hell
+│
+├─ Need imperative control from parent (focus, scroll, reset)
+│  └─ forwardRef + useImperativeHandle
+│
+├─ Render content outside DOM hierarchy (modal, tooltip, toast)
+│  └─ Portal: createPortal(content, document.body)
+│
+├─ Accept arbitrary children/slots without prop drilling
+│  └─ Slot pattern via children, or named props (header, footer)
+│
+└─ Polymorphic rendering (button that renders as <a> or div)
+   └─ as prop pattern with TypeScript generics
+```
+
+## State Management Decision Tree
+
+```
+Where does this state live and who owns it?
+│
+├─ Only one component needs it
+│  └─ useState or useReducer (local state)
+│
+├─ A few nearby components need it
+│  └─ Lift state to nearest common ancestor + prop drilling
+│     (2-3 levels is fine)
+│
+├─ Many components need it, rarely changes
+│  (theme, locale, auth user)
+│  └─ React Context API
+│     Split contexts by update frequency
+│     Avoid single giant context
+│
+├─ Global client state, changes often
+│  (shopping cart, UI preferences, navigation)
+│  ├─ Simple/small app → Zustand (minimal boilerplate)
+│  ├─ Atomic updates, React Suspense integration → Jotai
+│  └─ Large team, time-travel debugging, complex logic → Redux Toolkit
+│
+├─ Server state (remote data, cache, sync)
+│  (API data, database queries)
+│  └─ TanStack Query (React Query)
+│     Handles: caching, background refetch, loading/error
+│     Don't use useState + useEffect for server data
+│
+└─ Form state
+   └─ React Hook Form + Zod validation
+      (controlled inputs are fine for simple forms)
+```
+
+## React 19 Quick Reference
+
+| Feature | API | Purpose |
+|---------|-----|---------|
+| `use()` hook | `use(promise)` / `use(context)` | Await promises in render, read context conditionally |
+| Actions | `async function action(formData)` | Async transitions with built-in pending state |
+| `useActionState` | `useActionState(action, initialState)` | Action result + pending state |
+| `useFormStatus` | `useFormStatus()` | Pending/data/method inside form |
+| `useOptimistic` | `useOptimistic(state, updateFn)` | Optimistic UI before server response |
+| React Compiler | Automatic memoization | Replaces most `memo`, `useMemo`, `useCallback` |
+| `ref` as prop | `<Input ref={ref}>` | No more forwardRef wrapper needed |
+| `<Context>` as provider | `<MyContext value={val}>` | No more `<MyContext.Provider>` |
+
+```tsx
+// React 19: use() for data fetching in Server Components
+import { use } from 'react';
+
+function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
+  const user = use(userPromise); // suspends until resolved
+  return <h1>{user.name}</h1>;
+}
+
+// React 19: useActionState
+import { useActionState } from 'react';
+
+function ContactForm() {
+  const [state, action, isPending] = useActionState(
+    async (prevState: State, formData: FormData) => {
+      const result = await submitContact(formData);
+      return result;
+    },
+    { error: null }
+  );
+
+  return (
+    <form action={action}>
+      <input name="email" type="email" />
+      <button disabled={isPending}>
+        {isPending ? 'Sending...' : 'Send'}
+      </button>
+      {state.error && <p>{state.error}</p>}
+    </form>
+  );
+}
+```
+
+## Server vs Client Components
+
+```
+Does this component need...?
+│
+├─ useState, useReducer, useContext
+│  └─ Client Component ('use client')
+│
+├─ useEffect, useLayoutEffect
+│  └─ Client Component ('use client')
+│
+├─ Browser APIs (window, document, localStorage)
+│  └─ Client Component ('use client')
+│
+├─ Event handlers (onClick, onChange, onSubmit)
+│  └─ Client Component ('use client')
+│
+├─ Third-party libraries that use hooks/browser APIs
+│  └─ Client Component ('use client')
+│
+├─ Direct database/file system access
+│  └─ Server Component (default, no directive)
+│
+├─ Access to env vars (server-only secrets)
+│  └─ Server Component
+│
+├─ Large dependencies you want to keep off the client bundle
+│  └─ Server Component
+│
+└─ async/await at the top level
+   └─ Server Component
+```
+
+**Client boundary rules:**
+- `'use client'` marks a boundary — everything imported below it becomes client JS
+- Server Components can import Client Components (they pass as props/children)
+- Client Components CANNOT import Server Components directly
+- Pass Server Component output as `children` prop to Client Components
+- Server data → Client: pass as serializable props only (no functions, classes, DOM nodes)
+
+## Performance Checklist
+
+| Technique | When to Use | When NOT to Use |
+|-----------|-------------|-----------------|
+| `React.memo` | Component re-renders often with same props | Nearly everything — adds comparison overhead |
+| `useMemo` | Expensive calculation (>1ms), stable dep array | Primitive values, simple expressions |
+| `useCallback` | Callback passed to memoized child or in dep array | Inline handlers on DOM elements |
+| `React.lazy` + `Suspense` | Large components not needed on initial load | Small components, SSR-critical content |
+| `useTransition` | Non-urgent state updates (filtering, sorting) | Time-sensitive UI (typing, hover) |
+| `useDeferredValue` | Derived expensive render from fast-changing value | Same as above |
+| Virtualization | Lists >100 items | Small lists — overhead not worth it |
+| React Compiler (v19) | Automatic — replaces most manual memoization | Opt-out with `"use no memo"` if needed |
+
+## Common Gotchas
+
+| Gotcha | Why It Happens | Fix |
+|--------|---------------|-----|
+| Stale closure in useEffect | Callback captures old state/prop at definition time | Add value to dep array, or use functional update `setState(prev => ...)` |
+| Missing useEffect dependency | Linter disabled or ignored, stale data shown | Never disable exhaustive-deps; use `useCallback` to stabilize functions |
+| Index as list key | Keys change on reorder/insert, causing wrong component identity | Use stable unique ID from data (`item.id`) |
+| Hydration mismatch | Server HTML doesn't match first client render | Avoid `typeof window`, random values, or dates in render; use `useEffect` for client-only content |
+| Unnecessary re-renders from context | All consumers re-render when any context value changes | Split context by concern; memoize context value with `useMemo` |
+| useEffect for derived state | State derived from another state causes extra render cycle | Compute derived value during render inline or with `useMemo` |
+| Missing cleanup in useEffect | Memory leaks from subscriptions, timers, fetch requests | Always return cleanup function; use AbortController for fetch |
+| Strict Mode double invocation | Effects run twice in dev to catch bugs | Design effects to be idempotent; cleanup must fully reverse effect |
+| Controlled/uncontrolled switch | `value` prop toggling between defined and `undefined` | Always provide defined value or always use `defaultValue`; never both |
+| Object/array in dep array | New reference every render triggers effect repeatedly | Memoize with `useMemo`; use primitive values in deps where possible |
+| Async function directly in useEffect | `useEffect(() => async () => {})` returns a Promise, not cleanup | Wrap: `useEffect(() => { async function run() {...}; run(); }, [])` |
+
+## Reference Files
+
+| File | When to Load |
+|------|-------------|
+| `./references/hooks-patterns.md` | Deep hook usage: custom hooks, React 19 hooks, useEffect patterns, hook composition |
+| `./references/component-architecture.md` | Compound components, HOC, render props, portals, forwardRef, polymorphic components |
+| `./references/state-management.md` | Context API, Zustand, Jotai, Redux Toolkit, TanStack Query, React Hook Form |
+| `./references/server-components.md` | RSC architecture, Server Actions, Next.js App Router, caching, streaming, metadata |
+| `./references/performance.md` | React.memo, code splitting, virtualization, React Compiler, Web Vitals, profiling |
+| `./references/testing.md` | RTL queries, user-event, MSW, renderHook, Vitest setup, accessibility testing |
+
+## See Also
+
+| Skill | When to Combine |
+|-------|----------------|
+| `typescript-ops` | TypeScript generics with React props, discriminated unions for state machines, utility types |
+| `testing-ops` | Test strategy, mocking patterns, CI integration, snapshot vs behavioral tests |
+| `tailwind-ops` | CSS-in-JS alternatives, responsive design with Tailwind in React components |
+| `javascript-ops` | Async patterns, Promises, generators, module system fundamentals |

+ 0 - 0
skills/react-ops/assets/.gitkeep


+ 661 - 0
skills/react-ops/references/component-architecture.md

@@ -0,0 +1,661 @@
+# Component Architecture
+
+Patterns for structuring React components: compound components, HOC, render props, portals, refs, and polymorphic components.
+
+---
+
+## Compound Components
+
+Compound components share implicit state through Context. The parent owns state; children consume it without prop drilling.
+
+```tsx
+import {
+  createContext,
+  useContext,
+  useState,
+  ReactNode,
+  KeyboardEvent,
+} from 'react';
+
+// --- Types ---
+interface TabsContextValue {
+  activeIndex: number;
+  setActiveIndex: (index: number) => void;
+}
+
+// --- Context ---
+const TabsContext = createContext<TabsContextValue | null>(null);
+
+function useTabsContext() {
+  const ctx = useContext(TabsContext);
+  if (!ctx) throw new Error('Tabs sub-components must be used within <Tabs>');
+  return ctx;
+}
+
+// --- Compound Components ---
+
+function Tabs({
+  children,
+  defaultIndex = 0,
+}: {
+  children: ReactNode;
+  defaultIndex?: number;
+}) {
+  const [activeIndex, setActiveIndex] = useState(defaultIndex);
+  return (
+    <TabsContext.Provider value={{ activeIndex, setActiveIndex }}>
+      <div className="tabs">{children}</div>
+    </TabsContext.Provider>
+  );
+}
+
+function TabList({ children }: { children: ReactNode }) {
+  return (
+    <div role="tablist" className="tab-list">
+      {children}
+    </div>
+  );
+}
+
+function Tab({ children, index }: { children: ReactNode; index: number }) {
+  const { activeIndex, setActiveIndex } = useTabsContext();
+  const isActive = activeIndex === index;
+
+  const handleKeyDown = (e: KeyboardEvent) => {
+    if (e.key === 'Enter' || e.key === ' ') setActiveIndex(index);
+  };
+
+  return (
+    <button
+      role="tab"
+      aria-selected={isActive}
+      tabIndex={isActive ? 0 : -1}
+      onClick={() => setActiveIndex(index)}
+      onKeyDown={handleKeyDown}
+      className={isActive ? 'tab tab--active' : 'tab'}
+    >
+      {children}
+    </button>
+  );
+}
+
+function TabPanels({ children }: { children: ReactNode }) {
+  return <div className="tab-panels">{children}</div>;
+}
+
+function TabPanel({ children, index }: { children: ReactNode; index: number }) {
+  const { activeIndex } = useTabsContext();
+  if (activeIndex !== index) return null;
+  return (
+    <div role="tabpanel" className="tab-panel">
+      {children}
+    </div>
+  );
+}
+
+// Attach as static properties
+Tabs.List = TabList;
+Tabs.Tab = Tab;
+Tabs.Panels = TabPanels;
+Tabs.Panel = TabPanel;
+
+// --- Usage ---
+function App() {
+  return (
+    <Tabs defaultIndex={0}>
+      <Tabs.List>
+        <Tabs.Tab index={0}>Profile</Tabs.Tab>
+        <Tabs.Tab index={1}>Settings</Tabs.Tab>
+      </Tabs.List>
+      <Tabs.Panels>
+        <Tabs.Panel index={0}>Profile content</Tabs.Panel>
+        <Tabs.Panel index={1}>Settings content</Tabs.Panel>
+      </Tabs.Panels>
+    </Tabs>
+  );
+}
+```
+
+---
+
+## Render Props
+
+Render props delegate rendering to the consumer. Use for headless/unstyled component libraries where the logic is fixed but appearance varies.
+
+```tsx
+import { useState, ReactNode } from 'react';
+
+interface ToggleRenderProps {
+  on: boolean;
+  toggle: () => void;
+  setOn: (value: boolean) => void;
+}
+
+function Toggle({
+  initial = false,
+  children,
+}: {
+  initial?: boolean;
+  children: (props: ToggleRenderProps) => ReactNode;
+}) {
+  const [on, setOn] = useState(initial);
+  return <>{children({ on, toggle: () => setOn(v => !v), setOn })}</>;
+}
+
+// Usage: consumer controls rendering
+function DarkModeButton() {
+  return (
+    <Toggle>
+      {({ on, toggle }) => (
+        <button
+          onClick={toggle}
+          aria-label={on ? 'Switch to light mode' : 'Switch to dark mode'}
+        >
+          {on ? '🌙' : '☀️'}
+        </button>
+      )}
+    </Toggle>
+  );
+}
+
+// Prefer custom hooks over render props in modern React —
+// they achieve the same reuse with less JSX nesting
+function useToggle(initial = false) {
+  const [on, setOn] = useState(initial);
+  return { on, toggle: () => setOn(v => !v), setOn };
+}
+```
+
+---
+
+## Higher-Order Components (HOC)
+
+HOCs wrap a component to inject props or add behavior. Prefer custom hooks for pure logic; use HOCs when you need to conditionally render or wrap JSX.
+
+```tsx
+import { ComponentType, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+// --- Auth HOC ---
+interface WithAuthOptions {
+  redirectTo?: string;
+}
+
+function withAuth<P extends object>(
+  Component: ComponentType<P>,
+  options: WithAuthOptions = {}
+) {
+  const { redirectTo = '/login' } = options;
+
+  function AuthenticatedComponent(props: P) {
+    const { user, isLoading } = useAuth();
+    const navigate = useNavigate();
+
+    useEffect(() => {
+      if (!isLoading && !user) navigate(redirectTo);
+    }, [user, isLoading, navigate]);
+
+    if (isLoading) return <FullPageSpinner />;
+    if (!user) return null;
+
+    return <Component {...props} />;
+  }
+
+  // Preserve display name for DevTools
+  AuthenticatedComponent.displayName = `withAuth(${Component.displayName ?? Component.name})`;
+  return AuthenticatedComponent;
+}
+
+// Usage
+const ProtectedDashboard = withAuth(Dashboard);
+const AdminPanel = withAuth(AdminDashboard, { redirectTo: '/unauthorized' });
+
+// --- Logging HOC ---
+function withLogging<P extends object>(
+  Component: ComponentType<P>,
+  componentName: string
+) {
+  function LoggedComponent(props: P) {
+    useEffect(() => {
+      console.log(`[Mount] ${componentName}`);
+      return () => console.log(`[Unmount] ${componentName}`);
+    }, []);
+
+    return <Component {...props} />;
+  }
+
+  LoggedComponent.displayName = `withLogging(${componentName})`;
+  return LoggedComponent;
+}
+```
+
+---
+
+## Controlled vs Uncontrolled Components
+
+### Controlled
+
+```tsx
+import { useState } from 'react';
+
+// Controlled: parent owns and controls the value
+function ControlledInput({
+  value,
+  onChange,
+  label,
+}: {
+  value: string;
+  onChange: (value: string) => void;
+  label: string;
+}) {
+  return (
+    <label>
+      {label}
+      <input
+        type="text"
+        value={value}
+        onChange={e => onChange(e.target.value)}
+      />
+    </label>
+  );
+}
+
+function Parent() {
+  const [name, setName] = useState('');
+  return <ControlledInput value={name} onChange={setName} label="Name" />;
+}
+```
+
+### Uncontrolled with Imperative Handle
+
+```tsx
+import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
+
+interface InputHandle {
+  focus: () => void;
+  clear: () => void;
+  getValue: () => string;
+}
+
+// Hybrid: uncontrolled internally, but exposes imperative API via ref
+const SmartInput = forwardRef<InputHandle, { defaultValue?: string }>(
+  function SmartInput({ defaultValue = '' }, ref) {
+    const inputRef = useRef<HTMLInputElement>(null);
+    const [value, setValue] = useState(defaultValue);
+
+    useImperativeHandle(ref, () => ({
+      focus: () => inputRef.current?.focus(),
+      clear: () => setValue(''),
+      getValue: () => value,
+    }));
+
+    return (
+      <input
+        ref={inputRef}
+        value={value}
+        onChange={e => setValue(e.target.value)}
+      />
+    );
+  }
+);
+
+// Usage
+function Form() {
+  const inputRef = useRef<InputHandle>(null);
+
+  const handleSubmit = () => {
+    const value = inputRef.current?.getValue();
+    if (!value?.trim()) {
+      inputRef.current?.focus();
+      return;
+    }
+    submitForm(value);
+    inputRef.current?.clear();
+  };
+
+  return (
+    <>
+      <SmartInput ref={inputRef} defaultValue="" />
+      <button onClick={handleSubmit}>Submit</button>
+    </>
+  );
+}
+```
+
+---
+
+## Error Boundaries
+
+Error boundaries must be class components. Use `react-error-boundary` package in production for less boilerplate.
+
+```tsx
+import { Component, ErrorInfo, ReactNode } from 'react';
+
+interface Props {
+  children: ReactNode;
+  fallback: ReactNode | ((error: Error, reset: () => void) => ReactNode);
+  onError?: (error: Error, info: ErrorInfo) => void;
+}
+
+interface State {
+  hasError: boolean;
+  error: Error | null;
+}
+
+class ErrorBoundary extends Component<Props, State> {
+  state: State = { hasError: false, error: null };
+
+  static getDerivedStateFromError(error: Error): State {
+    return { hasError: true, error };
+  }
+
+  componentDidCatch(error: Error, info: ErrorInfo) {
+    // Log to error tracking service (Sentry, Datadog, etc.)
+    this.props.onError?.(error, info);
+    console.error('ErrorBoundary caught:', error, info.componentStack);
+  }
+
+  reset = () => this.setState({ hasError: false, error: null });
+
+  render() {
+    if (this.state.hasError && this.state.error) {
+      const { fallback } = this.props;
+      return typeof fallback === 'function'
+        ? fallback(this.state.error, this.reset)
+        : fallback;
+    }
+    return this.props.children;
+  }
+}
+
+// Usage with error recovery
+function App() {
+  return (
+    <ErrorBoundary
+      fallback={(error, reset) => (
+        <div role="alert">
+          <h2>Something went wrong</h2>
+          <p>{error.message}</p>
+          <button onClick={reset}>Try Again</button>
+        </div>
+      )}
+      onError={(error) => Sentry.captureException(error)}
+    >
+      <Dashboard />
+    </ErrorBoundary>
+  );
+}
+
+// react-error-boundary package (recommended for production)
+import { ErrorBoundary } from 'react-error-boundary';
+
+function ErrorFallback({ error, resetErrorBoundary }: {
+  error: Error;
+  resetErrorBoundary: () => void;
+}) {
+  return (
+    <div role="alert">
+      <p>{error.message}</p>
+      <button onClick={resetErrorBoundary}>Retry</button>
+    </div>
+  );
+}
+
+<ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => queryClient.clear()}>
+  <App />
+</ErrorBoundary>
+```
+
+---
+
+## Portals
+
+Portals render children into a DOM node outside the current React tree. Useful for modals, tooltips, and toasts that need to escape overflow/z-index constraints.
+
+```tsx
+import { createPortal } from 'react-dom';
+import { useEffect, useRef, ReactNode } from 'react';
+
+function Modal({
+  isOpen,
+  onClose,
+  children,
+  title,
+}: {
+  isOpen: boolean;
+  onClose: () => void;
+  children: ReactNode;
+  title: string;
+}) {
+  const dialogRef = useRef<HTMLDialogElement>(null);
+
+  // Trap focus and handle Escape key
+  useEffect(() => {
+    const dialog = dialogRef.current;
+    if (!dialog) return;
+
+    if (isOpen) {
+      dialog.showModal();
+    } else {
+      dialog.close();
+    }
+  }, [isOpen]);
+
+  if (!isOpen) return null;
+
+  // Renders outside current DOM tree, into document.body
+  return createPortal(
+    <dialog
+      ref={dialogRef}
+      aria-labelledby="modal-title"
+      aria-modal="true"
+      onClose={onClose}
+    >
+      <h2 id="modal-title">{title}</h2>
+      <div>{children}</div>
+      <button onClick={onClose} aria-label="Close modal">
+        &times;
+      </button>
+    </dialog>,
+    document.body
+  );
+}
+```
+
+---
+
+## forwardRef
+
+```tsx
+import { forwardRef, InputHTMLAttributes } from 'react';
+
+interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
+  label: string;
+  error?: string;
+}
+
+// React 19: ref is now a regular prop, forwardRef not required
+// For React 18 and below:
+const Input = forwardRef<HTMLInputElement, InputProps>(
+  function Input({ label, error, id, ...props }, ref) {
+    const inputId = id ?? label.toLowerCase().replace(/\s+/g, '-');
+
+    return (
+      <div className="input-wrapper">
+        <label htmlFor={inputId}>{label}</label>
+        <input
+          ref={ref}
+          id={inputId}
+          aria-describedby={error ? `${inputId}-error` : undefined}
+          aria-invalid={!!error}
+          {...props}
+        />
+        {error && (
+          <span id={`${inputId}-error`} role="alert" className="error">
+            {error}
+          </span>
+        )}
+      </div>
+    );
+  }
+);
+
+Input.displayName = 'Input';
+
+// React 19 equivalent (no forwardRef needed):
+function InputV19({ label, ref, error, id, ...props }: InputProps & {
+  ref?: React.Ref<HTMLInputElement>;
+}) {
+  const inputId = id ?? label.toLowerCase().replace(/\s+/g, '-');
+  return (
+    <div>
+      <label htmlFor={inputId}>{label}</label>
+      <input ref={ref} id={inputId} {...props} />
+    </div>
+  );
+}
+```
+
+---
+
+## Slot Pattern
+
+Named slots via props allow flexible composition without rigid component trees.
+
+```tsx
+import { ReactNode } from 'react';
+
+interface CardProps {
+  header: ReactNode;
+  children: ReactNode;
+  footer?: ReactNode;
+  aside?: ReactNode;
+}
+
+function Card({ header, children, footer, aside }: CardProps) {
+  return (
+    <div className="card">
+      <div className="card__header">{header}</div>
+      <div className="card__body">
+        <div className="card__content">{children}</div>
+        {aside && <aside className="card__aside">{aside}</aside>}
+      </div>
+      {footer && <footer className="card__footer">{footer}</footer>}
+    </div>
+  );
+}
+
+// Usage: consumer fills each slot independently
+function ProductCard({ product }: { product: Product }) {
+  return (
+    <Card
+      header={<img src={product.image} alt={product.name} />}
+      footer={<AddToCartButton productId={product.id} />}
+      aside={<ProductRating rating={product.rating} />}
+    >
+      <h3>{product.name}</h3>
+      <p>{product.description}</p>
+    </Card>
+  );
+}
+```
+
+---
+
+## Polymorphic Components (as prop)
+
+```tsx
+import { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react';
+
+// Generic polymorphic component type
+type PolymorphicProps<C extends ElementType, P = object> = {
+  as?: C;
+  children?: ReactNode;
+} & P &
+  Omit<ComponentPropsWithoutRef<C>, keyof P | 'as' | 'children'>;
+
+// Button that can render as <button>, <a>, or any element
+function Button<C extends ElementType = 'button'>({
+  as,
+  children,
+  variant = 'primary',
+  ...props
+}: PolymorphicProps<C, { variant?: 'primary' | 'secondary' | 'ghost' }>) {
+  const Component = as ?? 'button';
+  return (
+    <Component className={`btn btn--${variant}`} {...props}>
+      {children}
+    </Component>
+  );
+}
+
+// Usage — TypeScript infers correct HTML attributes
+<Button onClick={() => {}}>Click me</Button>             // renders <button>
+<Button as="a" href="/about">About</Button>               // renders <a>, href is valid
+<Button as="a" href="/about" variant="secondary">Link</Button>
+```
+
+---
+
+## Container / Presentational Split
+
+Largely superseded by hooks, but useful when separating data-fetching from display for testing.
+
+```tsx
+// Presentational: receives data as props, no fetching
+function UserListView({
+  users,
+  isLoading,
+  error,
+  onDelete,
+}: {
+  users: User[];
+  isLoading: boolean;
+  error: Error | null;
+  onDelete: (id: string) => void;
+}) {
+  if (isLoading) return <Spinner />;
+  if (error) return <ErrorMessage error={error} />;
+  return (
+    <ul>
+      {users.map(user => (
+        <li key={user.id}>
+          {user.name}
+          <button onClick={() => onDelete(user.id)}>Delete</button>
+        </li>
+      ))}
+    </ul>
+  );
+}
+
+// Container: owns data-fetching, passes to presentational
+function UserListContainer() {
+  const { data: users = [], isLoading, error } = useQuery(['users'], fetchUsers);
+  const deleteMutation = useMutation(deleteUser, {
+    onSuccess: () => queryClient.invalidateQueries(['users']),
+  });
+
+  return (
+    <UserListView
+      users={users}
+      isLoading={isLoading}
+      error={error ?? null}
+      onDelete={id => deleteMutation.mutate(id)}
+    />
+  );
+}
+```
+
+---
+
+## Patterns to Avoid
+
+| Anti-pattern | Problem | Fix |
+|--------------|---------|-----|
+| Prop drilling past 3 levels | Hard to maintain, tightly coupled | Compound components or Context |
+| HOC for pure logic (no JSX needed) | Creates wrapper component unnecessarily | Custom hook instead |
+| Huge single component (500+ lines) | Hard to test, reuse, understand | Split by responsibility |
+| `any` in component props | Loses type safety | Type all props; use `unknown` with narrowing |
+| `key` on React.Fragment without need | Unnecessary | Only add key when rendering lists |
+| Mutable props | Breaks React's unidirectional data flow | Lift state or use callback |
+| Boolean props without clear intent | `<Input disabled />` vs `<Input disabled={false}>` | Always explicit: `disabled={isLoading}` |

+ 788 - 0
skills/react-ops/references/hooks-patterns.md

@@ -0,0 +1,788 @@
+# Hooks Patterns
+
+Deep reference for React hooks — built-in hooks, custom hook recipes, React 19 hooks, and composition patterns.
+
+---
+
+## useState
+
+### Initializer Function (Lazy Initial State)
+
+When initial state is expensive to compute, pass a function — it runs only once.
+
+```typescript
+import { useState } from 'react';
+
+// BAD: parseExpensiveData runs on every render
+const [data, setData] = useState(parseExpensiveData(rawInput));
+
+// GOOD: runs once at mount
+const [data, setData] = useState(() => parseExpensiveData(rawInput));
+
+// GOOD: reading from localStorage (sync, only once)
+const [theme, setTheme] = useState<'light' | 'dark'>(
+  () => (localStorage.getItem('theme') as 'light' | 'dark') ?? 'light'
+);
+```
+
+### Functional Updates
+
+When new state depends on previous state, always use the functional form to avoid stale closures.
+
+```typescript
+function Counter() {
+  const [count, setCount] = useState(0);
+
+  // BAD: if called rapidly, `count` might be stale
+  const increment = () => setCount(count + 1);
+
+  // GOOD: always receives the latest state
+  const increment = () => setCount(prev => prev + 1);
+
+  // GOOD: batch multiple updates
+  const incrementBy3 = () => {
+    setCount(prev => prev + 1);
+    setCount(prev => prev + 1);
+    setCount(prev => prev + 1);
+  };
+
+  return <button onClick={increment}>{count}</button>;
+}
+```
+
+### Object State
+
+```typescript
+interface FormState {
+  name: string;
+  email: string;
+  age: number;
+}
+
+function ProfileForm() {
+  const [form, setForm] = useState<FormState>({
+    name: '',
+    email: '',
+    age: 0,
+  });
+
+  // Partial update pattern — spread to preserve other fields
+  const updateField = <K extends keyof FormState>(
+    key: K,
+    value: FormState[K]
+  ) => setForm(prev => ({ ...prev, [key]: value }));
+
+  return (
+    <input
+      value={form.name}
+      onChange={e => updateField('name', e.target.value)}
+    />
+  );
+}
+```
+
+---
+
+## useReducer
+
+Use when state transitions are complex, involve multiple sub-values, or next state depends on previous in non-trivial ways.
+
+```typescript
+import { useReducer } from 'react';
+
+// 1. Define state shape
+interface CartState {
+  items: CartItem[];
+  total: number;
+  isCheckingOut: boolean;
+}
+
+// 2. Define discriminated union of actions
+type CartAction =
+  | { type: 'ADD_ITEM'; payload: CartItem }
+  | { type: 'REMOVE_ITEM'; payload: { id: string } }
+  | { type: 'CLEAR_CART' }
+  | { type: 'SET_CHECKOUT'; payload: boolean };
+
+// 3. Reducer — pure function, no side effects
+function cartReducer(state: CartState, action: CartAction): CartState {
+  switch (action.type) {
+    case 'ADD_ITEM':
+      return {
+        ...state,
+        items: [...state.items, action.payload],
+        total: state.total + action.payload.price,
+      };
+    case 'REMOVE_ITEM': {
+      const removed = state.items.find(i => i.id === action.payload.id);
+      return {
+        ...state,
+        items: state.items.filter(i => i.id !== action.payload.id),
+        total: state.total - (removed?.price ?? 0),
+      };
+    }
+    case 'CLEAR_CART':
+      return { items: [], total: 0, isCheckingOut: false };
+    case 'SET_CHECKOUT':
+      return { ...state, isCheckingOut: action.payload };
+    default:
+      // TypeScript exhaustiveness check
+      action satisfies never;
+      return state;
+  }
+}
+
+const initialState: CartState = { items: [], total: 0, isCheckingOut: false };
+
+function Cart() {
+  const [state, dispatch] = useReducer(cartReducer, initialState);
+
+  return (
+    <div>
+      <p>Items: {state.items.length}</p>
+      <p>Total: ${state.total}</p>
+      <button onClick={() => dispatch({ type: 'CLEAR_CART' })}>Clear</button>
+    </div>
+  );
+}
+```
+
+---
+
+## useRef
+
+### DOM Access
+
+```typescript
+import { useRef, useEffect } from 'react';
+
+function AutoFocusInput() {
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  useEffect(() => {
+    // ref.current is the DOM node after mount
+    inputRef.current?.focus();
+  }, []);
+
+  return <input ref={inputRef} placeholder="Auto-focused" />;
+}
+```
+
+### Mutable Value (No Re-render)
+
+```typescript
+function Stopwatch() {
+  const [elapsed, setElapsed] = useState(0);
+  // Store timer ID without triggering re-renders
+  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
+
+  const start = () => {
+    if (intervalRef.current !== null) return;
+    intervalRef.current = setInterval(() => {
+      setElapsed(prev => prev + 1);
+    }, 1000);
+  };
+
+  const stop = () => {
+    if (intervalRef.current === null) return;
+    clearInterval(intervalRef.current);
+    intervalRef.current = null;
+  };
+
+  // Clean up on unmount
+  useEffect(() => () => stop(), []);
+
+  return (
+    <div>
+      <p>{elapsed}s</p>
+      <button onClick={start}>Start</button>
+      <button onClick={stop}>Stop</button>
+    </div>
+  );
+}
+```
+
+---
+
+## useEffect
+
+### Cleanup Pattern
+
+Every subscription, timer, or fetch should have a cleanup.
+
+```typescript
+import { useEffect, useState } from 'react';
+
+// Pattern: subscription with cleanup
+function useWindowSize() {
+  const [size, setSize] = useState({
+    width: window.innerWidth,
+    height: window.innerHeight,
+  });
+
+  useEffect(() => {
+    const handler = () => {
+      setSize({ width: window.innerWidth, height: window.innerHeight });
+    };
+
+    window.addEventListener('resize', handler);
+
+    // Cleanup removes listener — runs before next effect and on unmount
+    return () => window.removeEventListener('resize', handler);
+  }, []); // empty array = run once at mount
+
+  return size;
+}
+```
+
+### Async in useEffect
+
+```typescript
+useEffect(() => {
+  // WRONG: async function returns Promise, not cleanup
+  // useEffect(async () => { ... }, []);
+
+  // CORRECT: define async function, call it immediately
+  const controller = new AbortController();
+
+  async function fetchData() {
+    try {
+      const res = await fetch(`/api/users/${userId}`, {
+        signal: controller.signal,
+      });
+      const data = await res.json();
+      setUser(data);
+    } catch (err) {
+      if (err instanceof Error && err.name !== 'AbortError') {
+        setError(err);
+      }
+    }
+  }
+
+  fetchData();
+
+  // Abort in-flight request if userId changes or component unmounts
+  return () => controller.abort();
+}, [userId]);
+```
+
+### useLayoutEffect vs useEffect
+
+```typescript
+import { useLayoutEffect, useEffect, useRef } from 'react';
+
+// useLayoutEffect: fires synchronously AFTER DOM mutations, BEFORE paint
+// Use for: measuring DOM, preventing visual flicker
+function Tooltip({ anchorRef }: { anchorRef: React.RefObject<HTMLElement> }) {
+  const tooltipRef = useRef<HTMLDivElement>(null);
+
+  useLayoutEffect(() => {
+    // Measure anchor position and position tooltip BEFORE browser paints
+    const anchor = anchorRef.current;
+    const tooltip = tooltipRef.current;
+    if (!anchor || !tooltip) return;
+
+    const rect = anchor.getBoundingClientRect();
+    tooltip.style.top = `${rect.bottom + 8}px`;
+    tooltip.style.left = `${rect.left}px`;
+  });
+
+  return <div ref={tooltipRef} className="tooltip">Tooltip</div>;
+}
+
+// useEffect: fires asynchronously AFTER paint
+// Use for: data fetching, subscriptions, analytics — anything that doesn't
+// need to block the browser paint
+```
+
+---
+
+## Custom Hooks
+
+### useFetch with AbortController
+
+```typescript
+import { useState, useEffect, useCallback } from 'react';
+
+interface FetchState<T> {
+  data: T | null;
+  error: Error | null;
+  isLoading: boolean;
+}
+
+function useFetch<T>(url: string) {
+  const [state, setState] = useState<FetchState<T>>({
+    data: null,
+    error: null,
+    isLoading: true,
+  });
+
+  const refetch = useCallback(() => {
+    const controller = new AbortController();
+    setState(prev => ({ ...prev, isLoading: true, error: null }));
+
+    fetch(url, { signal: controller.signal })
+      .then(res => {
+        if (!res.ok) throw new Error(`HTTP ${res.status}`);
+        return res.json() as Promise<T>;
+      })
+      .then(data => setState({ data, error: null, isLoading: false }))
+      .catch(err => {
+        if (err.name !== 'AbortError') {
+          setState({ data: null, error: err, isLoading: false });
+        }
+      });
+
+    return () => controller.abort();
+  }, [url]);
+
+  useEffect(() => {
+    const cleanup = refetch();
+    return cleanup;
+  }, [refetch]);
+
+  return { ...state, refetch };
+}
+
+// Usage
+function UserProfile({ id }: { id: string }) {
+  const { data, error, isLoading, refetch } = useFetch<User>(`/api/users/${id}`);
+
+  if (isLoading) return <Spinner />;
+  if (error) return <Error message={error.message} onRetry={refetch} />;
+  return <div>{data?.name}</div>;
+}
+```
+
+### useLocalStorage (SSR-safe)
+
+```typescript
+import { useState, useEffect, useCallback } from 'react';
+
+function useLocalStorage<T>(key: string, initialValue: T) {
+  // Read from localStorage with SSR safety
+  const readValue = useCallback((): T => {
+    if (typeof window === 'undefined') return initialValue;
+    try {
+      const item = window.localStorage.getItem(key);
+      return item ? (JSON.parse(item) as T) : initialValue;
+    } catch {
+      console.warn(`Error reading localStorage key "${key}"`);
+      return initialValue;
+    }
+  }, [key, initialValue]);
+
+  const [storedValue, setStoredValue] = useState<T>(readValue);
+
+  const setValue = useCallback(
+    (value: T | ((val: T) => T)) => {
+      try {
+        const valueToStore =
+          value instanceof Function ? value(storedValue) : value;
+        setStoredValue(valueToStore);
+        if (typeof window !== 'undefined') {
+          window.localStorage.setItem(key, JSON.stringify(valueToStore));
+        }
+      } catch {
+        console.warn(`Error setting localStorage key "${key}"`);
+      }
+    },
+    [key, storedValue]
+  );
+
+  // Sync across tabs
+  useEffect(() => {
+    const handleStorageChange = (event: StorageEvent) => {
+      if (event.key === key) {
+        setStoredValue(readValue());
+      }
+    };
+    window.addEventListener('storage', handleStorageChange);
+    return () => window.removeEventListener('storage', handleStorageChange);
+  }, [key, readValue]);
+
+  return [storedValue, setValue] as const;
+}
+```
+
+### useDebounce
+
+```typescript
+import { useState, useEffect } from 'react';
+
+function useDebounce<T>(value: T, delay: number): T {
+  const [debouncedValue, setDebouncedValue] = useState<T>(value);
+
+  useEffect(() => {
+    const timer = setTimeout(() => setDebouncedValue(value), delay);
+    return () => clearTimeout(timer);
+  }, [value, delay]);
+
+  return debouncedValue;
+}
+
+// Usage: debounce search input before firing API call
+function SearchBar() {
+  const [query, setQuery] = useState('');
+  const debouncedQuery = useDebounce(query, 300);
+
+  useEffect(() => {
+    if (debouncedQuery) {
+      searchApi(debouncedQuery);
+    }
+  }, [debouncedQuery]);
+
+  return (
+    <input
+      value={query}
+      onChange={e => setQuery(e.target.value)}
+      placeholder="Search..."
+    />
+  );
+}
+```
+
+### useMediaQuery
+
+```typescript
+import { useState, useEffect } from 'react';
+
+function useMediaQuery(query: string): boolean {
+  const [matches, setMatches] = useState<boolean>(() => {
+    if (typeof window === 'undefined') return false;
+    return window.matchMedia(query).matches;
+  });
+
+  useEffect(() => {
+    if (typeof window === 'undefined') return;
+    const mql = window.matchMedia(query);
+    const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
+
+    // Use addEventListener (deprecated addListener removed in modern browsers)
+    mql.addEventListener('change', handler);
+    return () => mql.removeEventListener('change', handler);
+  }, [query]);
+
+  return matches;
+}
+
+// Predefined breakpoints matching Tailwind defaults
+export const useIsTablet = () => useMediaQuery('(min-width: 768px)');
+export const useIsDesktop = () => useMediaQuery('(min-width: 1024px)');
+export const usePrefersDark = () => useMediaQuery('(prefers-color-scheme: dark)');
+export const usePrefersReducedMotion = () =>
+  useMediaQuery('(prefers-reduced-motion: reduce)');
+```
+
+### useIntersectionObserver
+
+```typescript
+import { useEffect, useRef, useState } from 'react';
+
+interface UseIntersectionOptions extends IntersectionObserverInit {
+  freezeOnceVisible?: boolean;
+}
+
+function useIntersectionObserver(options: UseIntersectionOptions = {}) {
+  const { threshold = 0, root = null, rootMargin = '0%', freezeOnceVisible = false } = options;
+  const elementRef = useRef<HTMLElement>(null);
+  const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null);
+
+  const frozen = entry?.isIntersecting && freezeOnceVisible;
+
+  useEffect(() => {
+    const element = elementRef.current;
+    if (!element || frozen) return;
+
+    const observer = new IntersectionObserver(
+      ([entry]) => setEntry(entry),
+      { threshold, root, rootMargin }
+    );
+
+    observer.observe(element);
+    return () => observer.disconnect();
+  }, [threshold, root, rootMargin, frozen]);
+
+  return { ref: elementRef, entry, isIntersecting: !!entry?.isIntersecting };
+}
+
+// Usage: lazy load images
+function LazyImage({ src, alt }: { src: string; alt: string }) {
+  const { ref, isIntersecting } = useIntersectionObserver({
+    threshold: 0.1,
+    freezeOnceVisible: true,
+  });
+
+  return (
+    <div ref={ref as React.RefObject<HTMLDivElement>} style={{ minHeight: 200 }}>
+      {isIntersecting && <img src={src} alt={alt} loading="lazy" />}
+    </div>
+  );
+}
+```
+
+### usePrevious
+
+```typescript
+import { useRef, useEffect } from 'react';
+
+function usePrevious<T>(value: T): T | undefined {
+  const ref = useRef<T | undefined>(undefined);
+
+  // Runs after render — ref holds value from previous render
+  useEffect(() => {
+    ref.current = value;
+  }, [value]);
+
+  // Returns value from before this render
+  return ref.current;
+}
+
+// Usage: animate on value change
+function AnimatedCounter({ count }: { count: number }) {
+  const prevCount = usePrevious(count);
+  const direction = prevCount !== undefined && count > prevCount ? 'up' : 'down';
+
+  return (
+    <span className={`animate-${direction}`}>
+      {count}
+    </span>
+  );
+}
+```
+
+### useEventListener
+
+```typescript
+import { useEffect, useRef } from 'react';
+
+function useEventListener<K extends keyof WindowEventMap>(
+  eventType: K,
+  handler: (event: WindowEventMap[K]) => void,
+  element: EventTarget = window
+): void {
+  // Use ref so handler changes don't cause re-subscription
+  const handlerRef = useRef(handler);
+  useEffect(() => { handlerRef.current = handler; });
+
+  useEffect(() => {
+    const listener = (event: Event) =>
+      handlerRef.current(event as WindowEventMap[K]);
+    element.addEventListener(eventType, listener);
+    return () => element.removeEventListener(eventType, listener);
+  }, [eventType, element]);
+}
+
+// Usage
+function KeyboardShortcut() {
+  useEventListener('keydown', event => {
+    if (event.key === 'Escape') closeModal();
+    if ((event.metaKey || event.ctrlKey) && event.key === 'k') openSearch();
+  });
+}
+```
+
+---
+
+## Hook Composition
+
+Build complex hooks by composing simpler ones. Each hook should do one thing well.
+
+```typescript
+// Compose useFetch + useDebounce for a search hook
+function useSearch<T>(endpoint: string) {
+  const [query, setQuery] = useState('');
+  const debouncedQuery = useDebounce(query, 300);
+
+  // Only fetch when query is non-empty
+  const url = debouncedQuery ? `${endpoint}?q=${encodeURIComponent(debouncedQuery)}` : null;
+  const { data, isLoading, error } = useFetch<T[]>(url ?? '');
+
+  return {
+    query,
+    setQuery,
+    results: data ?? [],
+    isLoading: isLoading && !!debouncedQuery,
+    error,
+  };
+}
+
+// Compose local storage + media query for responsive theme
+function useTheme() {
+  const prefersDark = useMediaQuery('(prefers-color-scheme: dark)');
+  const [savedTheme, setSavedTheme] = useLocalStorage<'light' | 'dark' | 'system'>(
+    'theme',
+    'system'
+  );
+
+  const resolvedTheme: 'light' | 'dark' =
+    savedTheme === 'system' ? (prefersDark ? 'dark' : 'light') : savedTheme;
+
+  return { theme: resolvedTheme, savedTheme, setTheme: setSavedTheme };
+}
+```
+
+---
+
+## Rules of Hooks
+
+Only call hooks at the top level of a React function component or another custom hook. Never inside conditions, loops, or nested functions.
+
+```typescript
+// VIOLATION: conditional hook call
+function BadComponent({ isLoggedIn }: { isLoggedIn: boolean }) {
+  if (isLoggedIn) {
+    const user = useUser(); // ERROR: conditional
+  }
+}
+
+// FIX: always call hooks, conditionally use their values
+function GoodComponent({ isLoggedIn }: { isLoggedIn: boolean }) {
+  const user = useUser();
+  if (!isLoggedIn) return null;
+  return <div>{user.name}</div>;
+}
+
+// VIOLATION: hook in a loop
+function BadList({ ids }: { ids: string[] }) {
+  return ids.map(id => {
+    const data = useFetch(`/api/${id}`); // ERROR: in loop
+    return <Item key={id} data={data} />;
+  });
+}
+
+// FIX: move hook logic into a child component
+function GoodList({ ids }: { ids: string[] }) {
+  return ids.map(id => <ListItem key={id} id={id} />);
+}
+
+function ListItem({ id }: { id: string }) {
+  const data = useFetch(`/api/${id}`); // CORRECT: top level
+  return <Item data={data} />;
+}
+```
+
+---
+
+## React 19 Hooks
+
+### use() — Promises and Context
+
+```typescript
+import { use, Suspense } from 'react';
+
+// Await a promise directly in render (must be wrapped in Suspense)
+async function fetchUser(id: string): Promise<User> {
+  const res = await fetch(`/api/users/${id}`);
+  return res.json();
+}
+
+function UserCard({ userPromise }: { userPromise: Promise<User> }) {
+  // Suspends until promise resolves; throws on rejection (ErrorBoundary handles it)
+  const user = use(userPromise);
+  return <div>{user.name}</div>;
+}
+
+function Page({ id }: { id: string }) {
+  const userPromise = fetchUser(id); // start fetch, pass promise down
+
+  return (
+    <Suspense fallback={<Skeleton />}>
+      <UserCard userPromise={userPromise} />
+    </Suspense>
+  );
+}
+
+// use() can also read context conditionally (unlike useContext)
+function ConditionalTheme({ showLabel }: { showLabel: boolean }) {
+  if (!showLabel) return null;
+  const theme = use(ThemeContext); // conditional — allowed with use()
+  return <span style={{ color: theme.primary }}>Label</span>;
+}
+```
+
+### useFormStatus
+
+```typescript
+import { useFormStatus } from 'react-dom';
+
+// Must be used inside a <form> with an action
+function SubmitButton() {
+  const { pending, data, method } = useFormStatus();
+  return (
+    <button type="submit" disabled={pending}>
+      {pending ? 'Saving...' : 'Save'}
+    </button>
+  );
+}
+
+function ProfileForm() {
+  return (
+    <form action={updateProfileAction}>
+      <input name="bio" />
+      <SubmitButton /> {/* useFormStatus works here */}
+    </form>
+  );
+}
+```
+
+### useOptimistic
+
+```typescript
+import { useOptimistic, useTransition } from 'react';
+
+interface Message {
+  id: string;
+  text: string;
+  sending?: boolean;
+}
+
+function MessageList({ messages }: { messages: Message[] }) {
+  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
+    messages,
+    // Reducer: how to merge optimistic update into current state
+    (currentMessages, newMessage: Message) => [
+      ...currentMessages,
+      { ...newMessage, sending: true },
+    ]
+  );
+
+  async function sendMessage(formData: FormData) {
+    const text = formData.get('text') as string;
+    const tempMessage = { id: crypto.randomUUID(), text };
+
+    // Update UI immediately
+    addOptimisticMessage(tempMessage);
+
+    // Send to server (optimistic update reverts on error)
+    await saveMessage(text);
+  }
+
+  return (
+    <>
+      {optimisticMessages.map(msg => (
+        <div key={msg.id} style={{ opacity: msg.sending ? 0.5 : 1 }}>
+          {msg.text}
+        </div>
+      ))}
+      <form action={sendMessage}>
+        <input name="text" />
+        <button type="submit">Send</button>
+      </form>
+    </>
+  );
+}
+```
+
+---
+
+## Anti-patterns
+
+| Anti-pattern | Problem | Fix |
+|--------------|---------|-----|
+| `useEffect` with no dep array syncing props to state | Runs every render | Compute derived value during render |
+| Calling hooks from event handlers | Violates rules of hooks | Move hook to component top level |
+| `useState` for server data | Manual loading/error state, stale data | Use TanStack Query |
+| Large single `useEffect` doing multiple things | Hard to reason about, wrong deps | Split into separate `useEffect` calls per concern |
+| `useCallback` on everything | Adds overhead, no benefit without memoized children | Only when callback is a dep or passed to `memo` component |
+| Forgetting cleanup | Memory leaks, stale updates on unmounted component | Always return cleanup from `useEffect` |

+ 650 - 0
skills/react-ops/references/performance.md

@@ -0,0 +1,650 @@
+# Performance
+
+React performance patterns: memoization, code splitting, virtualization, React Compiler, profiling, and Web Vitals.
+
+---
+
+## Memoization
+
+### React.memo
+
+Skips re-render when props haven't changed (shallow equality by default).
+
+```tsx
+import { memo, useCallback, useState } from 'react';
+
+interface ListItemProps {
+  item: { id: string; name: string; count: number };
+  onDelete: (id: string) => void;
+}
+
+// Memoize expensive list items so parent re-renders don't cascade
+const ListItem = memo(function ListItem({ item, onDelete }: ListItemProps) {
+  console.log(`Rendering ${item.name}`); // only logs when item or onDelete changes
+  return (
+    <li>
+      {item.name} ({item.count})
+      <button onClick={() => onDelete(item.id)}>Delete</button>
+    </li>
+  );
+});
+
+// Custom comparison — return true to SKIP re-render
+const ExpensiveChart = memo(
+  function ExpensiveChart({ data, config }: ChartProps) {
+    return <Canvas data={data} config={config} />;
+  },
+  (prevProps, nextProps) => {
+    // Only re-render if data length changes or config changes
+    return (
+      prevProps.data.length === nextProps.data.length &&
+      prevProps.config.type === nextProps.config.type
+    );
+  }
+);
+
+// Parent must stabilize callbacks with useCallback to benefit from memo
+function ItemList({ items }: { items: Item[] }) {
+  const [filter, setFilter] = useState('');
+
+  // Without useCallback, new function reference every render → memo is useless
+  const handleDelete = useCallback((id: string) => {
+    deleteItem(id);
+  }, []); // stable — no deps
+
+  return (
+    <ul>
+      {items.map(item => (
+        <ListItem key={item.id} item={item} onDelete={handleDelete} />
+      ))}
+    </ul>
+  );
+}
+```
+
+### When NOT to Use React.memo
+
+```tsx
+// BAD: memo on a component that almost always re-renders anyway
+const SimpleDiv = memo(({ children }: { children: React.ReactNode }) => (
+  <div>{children}</div>
+));
+
+// BAD: memo where props contain new objects/arrays every render
+function Parent() {
+  return (
+    // options is a new array every render — memo never skips
+    <MemoizedChild options={['a', 'b', 'c']} />
+  );
+}
+
+// GOOD: only memo when:
+// 1. Component renders the same output given the same props
+// 2. Re-renders frequently with same props (large lists, heavy computation)
+// 3. Props are primitives or stable references
+```
+
+### useMemo
+
+```tsx
+import { useMemo, useState } from 'react';
+
+function ProductList({ products }: { products: Product[] }) {
+  const [sortBy, setSortBy] = useState<'price' | 'name'>('name');
+  const [filter, setFilter] = useState('');
+
+  // Expensive: filter + sort on every render without memoization
+  const processedProducts = useMemo(() => {
+    const filtered = products.filter(p =>
+      p.name.toLowerCase().includes(filter.toLowerCase())
+    );
+    return filtered.sort((a, b) =>
+      sortBy === 'price' ? a.price - b.price : a.name.localeCompare(b.name)
+    );
+  }, [products, filter, sortBy]); // only recalculates when these change
+
+  return (
+    <ul>
+      {processedProducts.map(p => <ProductCard key={p.id} product={p} />)}
+    </ul>
+  );
+}
+
+// When NOT to use useMemo
+function BadUsage() {
+  // BAD: simple operations don't need memoization — the overhead costs more
+  const doubled = useMemo(() => count * 2, [count]);
+  const greeting = useMemo(() => `Hello, ${name}`, [name]);
+
+  // GOOD: compute inline
+  const doubled = count * 2;
+  const greeting = `Hello, ${name}`;
+}
+```
+
+### useCallback
+
+```tsx
+import { useCallback, useState, memo } from 'react';
+
+// useCallback returns a stable function reference
+// Only useful when passed to: memo() components, useEffect dep arrays, other callbacks
+
+function SearchPage() {
+  const [query, setQuery] = useState('');
+  const [results, setResults] = useState<Result[]>([]);
+
+  // Stable reference: won't cause SearchResults to re-render when SearchPage renders
+  const handleResultClick = useCallback((id: string) => {
+    trackClick(id); // does not depend on any state
+  }, []);
+
+  // Correct deps: includeArchived is used inside the callback
+  const [includeArchived, setIncludeArchived] = useState(false);
+  const search = useCallback(async (q: string) => {
+    const data = await fetchResults(q, { includeArchived });
+    setResults(data);
+  }, [includeArchived]); // re-created when includeArchived changes
+
+  return (
+    <>
+      <SearchInput value={query} onChange={setQuery} onSearch={search} />
+      <MemoizedResults results={results} onResultClick={handleResultClick} />
+    </>
+  );
+}
+```
+
+---
+
+## Code Splitting
+
+### React.lazy + Suspense
+
+```tsx
+import { lazy, Suspense, useState } from 'react';
+
+// Dynamic import — loaded only when rendered
+const HeavyEditor = lazy(() => import('./HeavyEditor'));
+const DataVizChart = lazy(() => import('./DataVizChart'));
+
+// Preload on hover for instant perceived load
+function preloadEditor() {
+  const promise = import('./HeavyEditor');
+  return promise;
+}
+
+function Dashboard() {
+  const [showEditor, setShowEditor] = useState(false);
+
+  return (
+    <div>
+      <button
+        onClick={() => setShowEditor(true)}
+        onMouseEnter={preloadEditor} // start loading before click
+      >
+        Open Editor
+      </button>
+
+      {showEditor && (
+        <Suspense fallback={<EditorSkeleton />}>
+          <HeavyEditor />
+        </Suspense>
+      )}
+
+      <Suspense fallback={<ChartSkeleton />}>
+        <DataVizChart />
+      </Suspense>
+    </div>
+  );
+}
+```
+
+### Route-Based Splitting (React Router)
+
+```tsx
+import { lazy, Suspense } from 'react';
+import { Routes, Route } from 'react-router-dom';
+
+// Each route is its own chunk
+const HomePage = lazy(() => import('./pages/Home'));
+const DashboardPage = lazy(() => import('./pages/Dashboard'));
+const SettingsPage = lazy(() => import('./pages/Settings'));
+
+function App() {
+  return (
+    <Suspense fallback={<PageLoader />}>
+      <Routes>
+        <Route path="/" element={<HomePage />} />
+        <Route path="/dashboard" element={<DashboardPage />} />
+        <Route path="/settings" element={<SettingsPage />} />
+      </Routes>
+    </Suspense>
+  );
+}
+```
+
+---
+
+## Avoiding Re-renders
+
+### State Colocation
+
+```tsx
+// BAD: state in parent causes all children to re-render
+function Parent() {
+  const [inputValue, setInputValue] = useState('');
+  return (
+    <>
+      <input value={inputValue} onChange={e => setInputValue(e.target.value)} />
+      <ExpensiveComponent /> {/* re-renders on every keystroke! */}
+      <AnotherExpensiveComponent />
+    </>
+  );
+}
+
+// GOOD: colocate state where it's needed
+function InputSection() {
+  const [inputValue, setInputValue] = useState('');
+  return <input value={inputValue} onChange={e => setInputValue(e.target.value)} />;
+}
+
+function Parent() {
+  return (
+    <>
+      <InputSection />       {/* only this re-renders */}
+      <ExpensiveComponent />  {/* never re-renders */}
+      <AnotherExpensiveComponent />
+    </>
+  );
+}
+```
+
+### Children Pattern
+
+```tsx
+// BAD: wrapping component re-renders on every parent render
+function Wrapper() {
+  const [count, setCount] = useState(0);
+  return (
+    <div>
+      <button onClick={() => setCount(c => c + 1)}>{count}</button>
+      <SlowComponent />  {/* re-renders even though it doesn't use count */}
+    </div>
+  );
+}
+
+// GOOD: pass slow component as children — it's created in parent, not re-rendered
+function WrapperWithChildren({ children }: { children: React.ReactNode }) {
+  const [count, setCount] = useState(0);
+  return (
+    <div>
+      <button onClick={() => setCount(c => c + 1)}>{count}</button>
+      {children} {/* reference is stable, SlowComponent doesn't re-render */}
+    </div>
+  );
+}
+
+function App() {
+  return (
+    <WrapperWithChildren>
+      <SlowComponent />
+    </WrapperWithChildren>
+  );
+}
+```
+
+---
+
+## Concurrent Features
+
+### useTransition
+
+```tsx
+import { useState, useTransition } from 'react';
+
+function FilterableList({ items }: { items: Item[] }) {
+  const [filter, setFilter] = useState('');
+  const [filteredItems, setFilteredItems] = useState(items);
+  const [isPending, startTransition] = useTransition();
+
+  const handleFilterChange = (value: string) => {
+    // Urgent: update input immediately
+    setFilter(value);
+
+    // Non-urgent: defer the expensive filtering
+    startTransition(() => {
+      const filtered = items.filter(item =>
+        item.name.toLowerCase().includes(value.toLowerCase())
+      );
+      setFilteredItems(filtered);
+    });
+  };
+
+  return (
+    <>
+      <input
+        value={filter}
+        onChange={e => handleFilterChange(e.target.value)}
+        placeholder="Filter..."
+      />
+      {/* Show stale content with opacity while pending */}
+      <ul style={{ opacity: isPending ? 0.7 : 1 }}>
+        {filteredItems.map(item => <li key={item.id}>{item.name}</li>)}
+      </ul>
+    </>
+  );
+}
+```
+
+### useDeferredValue
+
+```tsx
+import { useState, useDeferredValue, memo } from 'react';
+
+// useDeferredValue: defer a value derived from props/state
+// Unlike useTransition, works when you don't own the state setter
+
+function SearchResults({ query }: { query: string }) {
+  // Defer the slow part — input stays responsive
+  const deferredQuery = useDeferredValue(query);
+
+  return (
+    <div style={{ opacity: query !== deferredQuery ? 0.7 : 1 }}>
+      <SlowResultsList query={deferredQuery} />
+    </div>
+  );
+}
+
+// Must be memoized for useDeferredValue to have effect
+const SlowResultsList = memo(function SlowResultsList({ query }: { query: string }) {
+  // Expensive rendering — now deferred
+  const results = heavySearch(query);
+  return results.map(r => <Result key={r.id} result={r} />);
+});
+```
+
+---
+
+## Virtualization
+
+For lists with more than 100 items, only render what's visible.
+
+```tsx
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { useRef } from 'react';
+
+function VirtualList({ items }: { items: Item[] }) {
+  const parentRef = useRef<HTMLDivElement>(null);
+
+  const virtualizer = useVirtualizer({
+    count: items.length,
+    getScrollElement: () => parentRef.current,
+    estimateSize: () => 60, // estimated row height in px
+    overscan: 5,            // render 5 extra items outside viewport
+  });
+
+  return (
+    // Scrollable container — must have a fixed height
+    <div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
+      {/* Total height spacer so scrollbar is sized correctly */}
+      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
+        {virtualizer.getVirtualItems().map(virtualItem => (
+          <div
+            key={virtualItem.key}
+            style={{
+              position: 'absolute',
+              top: 0,
+              left: 0,
+              width: '100%',
+              height: `${virtualItem.size}px`,
+              transform: `translateY(${virtualItem.start}px)`,
+            }}
+          >
+            <ListItem item={items[virtualItem.index]} />
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}
+
+// Grid virtualizer
+function VirtualGrid({ items, columnCount = 3 }: { items: Item[]; columnCount?: number }) {
+  const parentRef = useRef<HTMLDivElement>(null);
+  const rowCount = Math.ceil(items.length / columnCount);
+
+  const rowVirtualizer = useVirtualizer({
+    count: rowCount,
+    getScrollElement: () => parentRef.current,
+    estimateSize: () => 200,
+  });
+
+  const columnVirtualizer = useVirtualizer({
+    horizontal: true,
+    count: columnCount,
+    getScrollElement: () => parentRef.current,
+    estimateSize: () => 300,
+  });
+
+  return (
+    <div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
+      <div
+        style={{
+          height: rowVirtualizer.getTotalSize(),
+          width: columnVirtualizer.getTotalSize(),
+          position: 'relative',
+        }}
+      >
+        {rowVirtualizer.getVirtualItems().map(row =>
+          columnVirtualizer.getVirtualItems().map(col => {
+            const index = row.index * columnCount + col.index;
+            if (index >= items.length) return null;
+            return (
+              <div
+                key={`${row.key}-${col.key}`}
+                style={{
+                  position: 'absolute',
+                  top: row.start,
+                  left: col.start,
+                  width: col.size,
+                  height: row.size,
+                }}
+              >
+                <GridItem item={items[index]} />
+              </div>
+            );
+          })
+        )}
+      </div>
+    </div>
+  );
+}
+```
+
+---
+
+## React Compiler (React 19)
+
+The React Compiler automatically applies memoization — most manual `memo`, `useMemo`, and `useCallback` calls become unnecessary.
+
+```tsx
+// Before React Compiler — manual memoization
+const ExpensiveList = memo(function ExpensiveList({ items, onDelete }: Props) {
+  const sorted = useMemo(() => [...items].sort((a, b) => a.name.localeCompare(b.name)), [items]);
+  const handleDelete = useCallback((id: string) => onDelete(id), [onDelete]);
+  return sorted.map(item => <Item key={item.id} item={item} onDelete={handleDelete} />);
+});
+
+// After React Compiler — compiler adds memoization automatically
+function ExpensiveList({ items, onDelete }: Props) {
+  const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name));
+  return sorted.map(item => <Item key={item.id} item={item} onDelete={onDelete} />);
+}
+
+// Opt out specific components if compiler breaks them
+function ProblematicComponent() {
+  "use no memo";
+  // ... compiler skips this component
+}
+```
+
+### Enabling React Compiler (Next.js)
+
+```javascript
+// next.config.js
+const nextConfig = {
+  experimental: {
+    reactCompiler: true,
+  },
+};
+
+// babel.config.js (for non-Next.js setups)
+module.exports = {
+  plugins: [['babel-plugin-react-compiler', {}]],
+};
+```
+
+---
+
+## Bundle Analysis
+
+```bash
+# Next.js bundle analyzer
+npm install @next/bundle-analyzer
+
+# next.config.js
+const withBundleAnalyzer = require('@next/bundle-analyzer')({
+  enabled: process.env.ANALYZE === 'true',
+});
+module.exports = withBundleAnalyzer({});
+
+# Run
+ANALYZE=true npm run build
+```
+
+```bash
+# source-map-explorer (framework-agnostic)
+npm install --save-dev source-map-explorer
+npx source-map-explorer 'build/static/js/*.js'
+```
+
+---
+
+## React DevTools Profiler
+
+```tsx
+// Mark component interactions for DevTools
+import { Profiler } from 'react';
+
+function onRenderCallback(
+  id: string,          // component tree id
+  phase: 'mount' | 'update',
+  actualDuration: number,  // time spent rendering
+  baseDuration: number,    // estimated full render time
+  startTime: number,
+  commitTime: number
+) {
+  if (actualDuration > 16) { // flag renders > 1 frame (16ms)
+    console.warn(`Slow render: ${id} took ${actualDuration.toFixed(2)}ms`);
+  }
+}
+
+function App() {
+  return (
+    <Profiler id="Dashboard" onRender={onRenderCallback}>
+      <Dashboard />
+    </Profiler>
+  );
+}
+```
+
+---
+
+## Web Vitals
+
+| Metric | Meaning | React Impact | Target |
+|--------|---------|-------------|--------|
+| LCP (Largest Contentful Paint) | When main content loads | Large component trees, unoptimized images | < 2.5s |
+| FID / INP (Interaction to Next Paint) | Response time to user input | Long tasks blocking main thread | < 200ms |
+| CLS (Cumulative Layout Shift) | Visual stability | Dynamic content without reserved space | < 0.1 |
+| TTFB (Time to First Byte) | Server response time | RSC data fetching efficiency | < 800ms |
+
+```tsx
+// Measure Web Vitals in Next.js
+// app/layout.tsx
+export function reportWebVitals(metric: NextWebVitalsMetric) {
+  if (metric.label === 'web-vital') {
+    // Send to analytics
+    analytics.track('web_vital', {
+      name: metric.name,
+      value: metric.value,
+      rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
+    });
+  }
+}
+
+// Avoiding CLS: always reserve space for dynamic content
+function Avatar({ src }: { src: string }) {
+  return (
+    // Fixed dimensions prevent layout shift when image loads
+    <div style={{ width: 40, height: 40 }}>
+      <img src={src} width={40} height={40} alt="" />
+    </div>
+  );
+}
+```
+
+---
+
+## Image Optimization
+
+```tsx
+import Image from 'next/image';
+
+// Optimized image with automatic WebP conversion, lazy loading, CLS prevention
+function ProductImage({ product }: { product: Product }) {
+  return (
+    <div style={{ position: 'relative', aspectRatio: '16/9' }}>
+      <Image
+        src={product.imageUrl}
+        alt={product.name}
+        fill                    // fills parent container
+        sizes="(max-width: 768px) 100vw, 50vw"  // responsive sizes hint
+        priority={false}        // true for above-fold LCP images
+        placeholder="blur"      // or "empty"
+        blurDataURL={product.blurDataUrl}
+      />
+    </div>
+  );
+}
+
+// LCP image — must be priority
+function HeroImage() {
+  return (
+    <Image
+      src="/hero.jpg"
+      alt="Hero"
+      width={1200}
+      height={600}
+      priority          // preload this image — no lazy loading
+    />
+  );
+}
+```
+
+---
+
+## Performance Anti-patterns
+
+| Anti-pattern | Problem | Fix |
+|--------------|---------|-----|
+| `memo` on everything | Comparison overhead, false optimization | Profile first; only memo when re-renders are measured problem |
+| `useMemo` for cheap computations | Overhead of memoization > cost of computation | Only memoize if computation takes >1ms |
+| `useCallback` without memoized consumers | Stable reference with no benefit | Only use when callback is dep in `useEffect` or passed to `memo` component |
+| No `key` strategy for lists | React unmounts/remounts on reorder | Stable unique IDs from data |
+| Inline object/array props on `memo` components | New reference every render defeats memo | `useMemo` the value or move outside component |
+| Not virtualizing long lists | Renders thousands of DOM nodes | Use `@tanstack/react-virtual` for 100+ items |
+| All JS in single bundle | Slow initial load | Route-based code splitting with `lazy` |
+| `useEffect` polling instead of WebSocket/SSE | Constant network requests | Switch to real-time transport |
+| Importing full lodash/moment | Huge bundle impact | Use tree-shakeable alternatives or native APIs |

+ 451 - 0
skills/react-ops/references/server-components.md

@@ -0,0 +1,451 @@
+# Server Components
+
+React Server Components (RSC), Server Actions, Next.js App Router patterns, caching, and streaming.
+
+---
+
+## RSC Architecture
+
+Server Components render on the server and send HTML (and a serialized React tree) to the client. They never ship their code to the browser.
+
+```
+Request
+   │
+   ▼
+Server Component Tree (renders on server)
+   │
+   ├─ Async data fetching (db, fs, fetch)
+   ├─ Heavy dependencies (never in client bundle)
+   └─ Client Component boundaries (marked 'use client')
+          │
+          ▼
+       Hydration (client takes over interactive parts only)
+```
+
+**Serialization rules — what can cross the server→client boundary:**
+- Strings, numbers, booleans, null, undefined
+- Arrays and plain objects of the above
+- Promises (unwrapped by `use()` on client)
+- JSX / React elements
+- **NOT**: functions, class instances, Date objects, Maps, Sets, RegExp (must be serialized or passed differently)
+
+---
+
+## Server Components
+
+```tsx
+// app/users/page.tsx — Server Component (default, no directive needed)
+import { db } from '@/lib/db';
+import { cache } from 'react';
+
+// cache() deduplicates calls within a single render pass
+const getUser = cache(async (id: string) => {
+  return db.query.users.findFirst({ where: eq(users.id, id) });
+});
+
+// Top-level async component — no useEffect, no loading state needed
+export default async function UsersPage() {
+  // Fetch in parallel — both start simultaneously
+  const [users, stats] = await Promise.all([
+    db.query.users.findMany({ limit: 50 }),
+    db.query.stats.findFirst(),
+  ]);
+
+  return (
+    <main>
+      <h1>Users ({stats?.total ?? 0})</h1>
+      <UserList users={users} />
+    </main>
+  );
+}
+```
+
+### What You Can Do in Server Components
+
+```tsx
+// 1. Database queries (Drizzle, Prisma, raw SQL)
+const posts = await db.select().from(postsTable).where(eq(postsTable.published, true));
+
+// 2. File system access
+import { readFile } from 'fs/promises';
+const content = await readFile('./data/content.md', 'utf8');
+
+// 3. Server-only secrets (never sent to client)
+const apiData = await fetch('https://api.example.com/data', {
+  headers: { Authorization: `Bearer ${process.env.SECRET_API_KEY}` },
+});
+
+// 4. Import heavy libraries without bundle cost
+import { parse } from 'some-huge-parser'; // 2MB — never in client bundle
+const result = parse(rawData);
+
+// 5. Conditional rendering based on server state/permissions
+const session = await auth();
+if (!session?.user) redirect('/login');
+```
+
+---
+
+## Client Components
+
+```tsx
+// components/counter.tsx
+'use client'; // marks this module and all its imports as client code
+
+import { useState, useEffect } from 'react';
+
+// Anything requiring hooks, browser APIs, or interactivity
+export function Counter({ initialCount = 0 }: { initialCount?: number }) {
+  const [count, setCount] = useState(initialCount);
+
+  useEffect(() => {
+    document.title = `Count: ${count}`;
+  }, [count]);
+
+  return (
+    <div>
+      <p>{count}</p>
+      <button onClick={() => setCount(c => c + 1)}>Increment</button>
+    </div>
+  );
+}
+```
+
+### Passing Server Data to Client Components
+
+```tsx
+// Server Component (parent)
+async function ProductPage({ id }: { id: string }) {
+  const product = await db.products.findUnique({ where: { id } });
+
+  // Pass serializable data as props
+  return (
+    <div>
+      <ProductImages images={product.images} /> {/* Server Component */}
+      <AddToCart
+        productId={product.id}  // string — serializable
+        price={product.price}    // number — serializable
+        // onAdd={addToCart}     // ERROR: functions can't cross boundary
+      />
+    </div>
+  );
+}
+
+// Pattern: pass Server Component output as children to Client Component
+async function Layout({ children }: { children: React.ReactNode }) {
+  const nav = await buildNavigation(); // server-only fetch
+  return (
+    <Shell nav={<ServerNav items={nav} />}> {/* Shell is Client Component */}
+      {children}
+    </Shell>
+  );
+}
+```
+
+---
+
+## Server Actions
+
+```tsx
+// app/actions.ts
+'use server'; // all exports are server actions
+
+import { revalidatePath, revalidateTag } from 'next/cache';
+import { redirect } from 'next/navigation';
+import { z } from 'zod';
+
+const createPostSchema = z.object({
+  title: z.string().min(1).max(200),
+  content: z.string().min(10),
+  published: z.coerce.boolean().default(false),
+});
+
+export async function createPost(formData: FormData) {
+  // Validate
+  const parsed = createPostSchema.safeParse(Object.fromEntries(formData));
+  if (!parsed.success) {
+    return { error: parsed.error.flatten().fieldErrors };
+  }
+
+  // Auth check
+  const session = await auth();
+  if (!session?.user) throw new Error('Unauthorized');
+
+  // Persist
+  const post = await db.posts.create({
+    data: { ...parsed.data, authorId: session.user.id },
+  });
+
+  // Invalidate cache
+  revalidatePath('/posts');
+  revalidateTag('posts');
+
+  // Redirect (throws internally, not caught by try/catch)
+  redirect(`/posts/${post.id}`);
+}
+
+// Progressive enhancement: works without JS, enhanced with JS
+export async function deletePost(id: string) {
+  await db.posts.delete({ where: { id } });
+  revalidatePath('/posts');
+}
+```
+
+### Form with Server Action
+
+```tsx
+// app/posts/new/page.tsx
+import { createPost } from '../actions';
+
+// Server Component — no 'use client' needed
+export default function NewPostPage() {
+  return (
+    <form action={createPost}>
+      <input name="title" placeholder="Post title" required />
+      <textarea name="content" placeholder="Content" required />
+      <label>
+        <input type="checkbox" name="published" value="true" />
+        Publish immediately
+      </label>
+      <button type="submit">Create Post</button>
+    </form>
+  );
+}
+```
+
+### Server Action with useActionState (React 19)
+
+```tsx
+'use client';
+
+import { useActionState } from 'react';
+import { createPost } from '../actions';
+
+type ActionState = { error?: Record<string, string[]>; message?: string } | null;
+
+export function CreatePostForm() {
+  const [state, action, isPending] = useActionState<ActionState, FormData>(
+    createPost,
+    null
+  );
+
+  return (
+    <form action={action}>
+      <input name="title" aria-invalid={!!state?.error?.title} />
+      {state?.error?.title && <p role="alert">{state.error.title[0]}</p>}
+
+      <textarea name="content" />
+      {state?.error?.content && <p role="alert">{state.error.content[0]}</p>}
+
+      <button disabled={isPending}>
+        {isPending ? 'Creating...' : 'Create Post'}
+      </button>
+    </form>
+  );
+}
+```
+
+---
+
+## Next.js App Router File Conventions
+
+```
+app/
+├── layout.tsx          # Shared layout (wraps all pages in segment)
+├── page.tsx            # Route UI (publicly accessible at URL)
+├── loading.tsx         # Suspense boundary skeleton (automatic)
+├── error.tsx           # Error boundary fallback (must be 'use client')
+├── not-found.tsx       # 404 UI (shown by notFound() call)
+├── route.ts            # API route handler (GET, POST, etc.)
+├── template.tsx        # Like layout but re-mounts on navigation
+└── (group)/            # Route group — parentheses = no URL segment
+    └── dashboard/
+        └── page.tsx    # app.com/dashboard
+```
+
+```tsx
+// app/layout.tsx
+import { Inter } from 'next/font/google';
+import type { Metadata } from 'next';
+
+const inter = Inter({ subsets: ['latin'] });
+
+export const metadata: Metadata = {
+  title: { template: '%s | MyApp', default: 'MyApp' },
+  description: 'My application',
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+  return (
+    <html lang="en">
+      <body className={inter.className}>{children}</body>
+    </html>
+  );
+}
+
+// app/posts/[id]/error.tsx — must be Client Component
+'use client';
+
+export default function PostError({
+  error,
+  reset,
+}: {
+  error: Error & { digest?: string };
+  reset: () => void;
+}) {
+  return (
+    <div role="alert">
+      <h2>Failed to load post</h2>
+      <p>{error.message}</p>
+      <button onClick={reset}>Try again</button>
+    </div>
+  );
+}
+
+// app/api/users/route.ts — API Route Handler
+import { NextRequest, NextResponse } from 'next/server';
+
+export async function GET(request: NextRequest) {
+  const { searchParams } = new URL(request.url);
+  const limit = Number(searchParams.get('limit') ?? '20');
+
+  const users = await db.users.findMany({ take: limit });
+  return NextResponse.json(users);
+}
+
+export async function POST(request: NextRequest) {
+  const body = await request.json();
+  const user = await db.users.create({ data: body });
+  return NextResponse.json(user, { status: 201 });
+}
+```
+
+---
+
+## Caching
+
+```tsx
+// 1. fetch() cache (Next.js extends native fetch)
+async function getPost(id: string) {
+  const res = await fetch(`https://api.example.com/posts/${id}`, {
+    next: {
+      revalidate: 3600, // revalidate every 1 hour (ISR)
+      tags: ['posts', `post-${id}`], // tag for on-demand revalidation
+    },
+    // cache: 'no-store'  // disable caching entirely (always fresh)
+    // cache: 'force-cache' // always use cache (default for static)
+  });
+  return res.json();
+}
+
+// 2. unstable_cache (for non-fetch data sources like ORMs)
+import { unstable_cache } from 'next/cache';
+
+const getCachedUsers = unstable_cache(
+  async () => db.users.findMany(),
+  ['users-list'],          // cache key
+  { revalidate: 300, tags: ['users'] } // 5 min TTL + tag
+);
+
+// 3. On-demand revalidation (Server Action or API route)
+import { revalidatePath, revalidateTag } from 'next/cache';
+
+export async function updatePost(id: string, data: Partial<Post>) {
+  await db.posts.update({ where: { id }, data });
+
+  revalidateTag(`post-${id}`);    // invalidate specific post cache
+  revalidateTag('posts');          // invalidate all posts list cache
+  revalidatePath('/posts');        // invalidate path-based cache
+  revalidatePath(`/posts/${id}`);
+}
+```
+
+---
+
+## Streaming with Suspense
+
+```tsx
+// Wrap slow components in Suspense — page loads instantly,
+// slow parts stream in progressively
+import { Suspense } from 'react';
+
+// app/dashboard/page.tsx
+export default function DashboardPage() {
+  return (
+    <div className="grid">
+      {/* Fast — renders immediately */}
+      <WelcomeHeader />
+
+      {/* Slow DB queries stream in independently */}
+      <Suspense fallback={<MetricsSkeleton />}>
+        <DashboardMetrics /> {/* async Server Component */}
+      </Suspense>
+
+      <Suspense fallback={<ActivitySkeleton />}>
+        <RecentActivity /> {/* async Server Component */}
+      </Suspense>
+
+      <Suspense fallback={<ChartSkeleton />}>
+        <RevenueChart /> {/* slow, streams last */}
+      </Suspense>
+    </div>
+  );
+}
+
+// loading.tsx provides automatic Suspense for the entire segment
+// app/dashboard/loading.tsx
+export default function DashboardLoading() {
+  return <DashboardSkeleton />;
+}
+```
+
+---
+
+## Metadata API
+
+```tsx
+// Static metadata
+export const metadata: Metadata = {
+  title: 'My Page',
+  description: 'Page description',
+  openGraph: {
+    title: 'My Page',
+    images: [{ url: '/og-image.png', width: 1200, height: 630 }],
+  },
+};
+
+// Dynamic metadata
+export async function generateMetadata(
+  { params }: { params: { id: string } }
+): Promise<Metadata> {
+  const post = await getPost(params.id);
+  if (!post) return { title: 'Post Not Found' };
+
+  return {
+    title: post.title,
+    description: post.excerpt,
+    openGraph: {
+      title: post.title,
+      images: [{ url: post.coverImage }],
+    },
+    alternates: {
+      canonical: `https://mysite.com/posts/${post.slug}`,
+    },
+  };
+}
+```
+
+---
+
+## Patterns to Avoid
+
+| Anti-pattern | Problem | Fix |
+|--------------|---------|-----|
+| `'use client'` at root layout | Entire app becomes client-side; no RSC benefits | Push `'use client'` to leaf components only |
+| Waterfall data fetching in Server Components | Each await blocks the next | `Promise.all()` for parallel fetches |
+| No Suspense boundaries | Entire page waits for slowest component | Wrap each async section in `<Suspense>` |
+| Server Action without validation | Security risk, bad UX | Always validate with Zod before DB write |
+| Fetching same data in multiple Server Components | Multiple DB queries for same data | `cache()` wrapper to deduplicate per request |
+| Passing non-serializable data to Client Components | Runtime error | Only pass strings, numbers, plain objects, JSX |
+| Large third-party imports in Client Components | Bloated client bundle | Move to Server Component; import only what's needed |
+| `cookies()` or `headers()` outside Server Components | Runtime error | Only in Server Components, Route Handlers, Server Actions |

+ 647 - 0
skills/react-ops/references/state-management.md

@@ -0,0 +1,647 @@
+# State Management
+
+Comprehensive reference for React state: Context API, Zustand, Jotai, Redux Toolkit, TanStack Query, React Hook Form, and URL state.
+
+---
+
+## Decision Matrix
+
+| State Type | Scope | Change Freq | Best Tool |
+|------------|-------|-------------|-----------|
+| Local UI (toggle, form input) | Component | Any | `useState` / `useReducer` |
+| Shared, rarely changes (theme, locale, auth) | App-wide | Low | Context API |
+| Global client state (cart, UI prefs) | App-wide | Medium-High | Zustand |
+| Atomic/fine-grained state | App-wide | High | Jotai |
+| Complex flows, large team, time-travel debug | App-wide | Any | Redux Toolkit |
+| Server data (API responses, cache) | App-wide | External | TanStack Query |
+| Form state | Component | High | React Hook Form |
+| URL-driven state (filters, pagination) | Shareable | Medium | `useSearchParams` / nuqs |
+
+---
+
+## Context API
+
+### Basic Pattern
+
+```tsx
+import { createContext, useContext, useState, useMemo, ReactNode } from 'react';
+
+interface ThemeContextValue {
+  theme: 'light' | 'dark';
+  toggleTheme: () => void;
+}
+
+// 1. Create context with null default (enforces provider requirement)
+const ThemeContext = createContext<ThemeContextValue | null>(null);
+
+// 2. Custom hook — single usage point, enforces provider
+export function useTheme(): ThemeContextValue {
+  const ctx = useContext(ThemeContext);
+  if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
+  return ctx;
+}
+
+// 3. Provider — memoize value to prevent unnecessary consumer re-renders
+export function ThemeProvider({ children }: { children: ReactNode }) {
+  const [theme, setTheme] = useState<'light' | 'dark'>('light');
+
+  // Memoize so object reference only changes when theme changes
+  const value = useMemo(
+    () => ({ theme, toggleTheme: () => setTheme(t => (t === 'light' ? 'dark' : 'light')) }),
+    [theme]
+  );
+
+  return (
+    <ThemeContext.Provider value={value}>
+      {children}
+    </ThemeContext.Provider>
+  );
+}
+```
+
+### Performance: Split Contexts by Update Frequency
+
+```tsx
+// BAD: single context — every consumer re-renders when ANY value changes
+const AppContext = createContext({ user, cart, theme, notifications });
+
+// GOOD: separate contexts — consumers only re-render for what they use
+const UserContext = createContext<User | null>(null);
+const CartContext = createContext<CartState | null>(null);
+const ThemeContext = createContext<Theme>('light');
+
+// BAD: context value recreated every render
+function BadProvider({ children }: { children: ReactNode }) {
+  const [count, setCount] = useState(0);
+  return (
+    // New object reference every render — all consumers re-render!
+    <MyContext.Provider value={{ count, setCount }}>
+      {children}
+    </MyContext.Provider>
+  );
+}
+
+// GOOD: memoized value
+function GoodProvider({ children }: { children: ReactNode }) {
+  const [count, setCount] = useState(0);
+  const value = useMemo(() => ({ count, setCount }), [count]);
+  return <MyContext.Provider value={value}>{children}</MyContext.Provider>;
+}
+```
+
+---
+
+## Zustand
+
+Minimal boilerplate, no providers needed, supports middleware.
+
+### Basic Store
+
+```typescript
+import { create } from 'zustand';
+import { devtools, persist } from 'zustand/middleware';
+
+interface BearState {
+  bears: number;
+  increase: (by?: number) => void;
+  reset: () => void;
+}
+
+const useBearStore = create<BearState>()(
+  devtools(
+    persist(
+      (set) => ({
+        bears: 0,
+        increase: (by = 1) => set(state => ({ bears: state.bears + by })),
+        reset: () => set({ bears: 0 }),
+      }),
+      { name: 'bear-storage' } // localStorage key
+    ),
+    { name: 'BearStore' } // DevTools display name
+  )
+);
+
+// Usage — select only what you need to minimize re-renders
+function BearCounter() {
+  const bears = useBearStore(state => state.bears);
+  return <p>{bears} bears</p>;
+}
+
+function BearControls() {
+  const increase = useBearStore(state => state.increase);
+  const reset = useBearStore(state => state.reset);
+  return (
+    <>
+      <button onClick={() => increase()}>+1</button>
+      <button onClick={() => increase(10)}>+10</button>
+      <button onClick={reset}>Reset</button>
+    </>
+  );
+}
+```
+
+### Slices Pattern (Large Stores)
+
+```typescript
+import { create, StateCreator } from 'zustand';
+
+// Slice 1: auth
+interface AuthSlice {
+  user: User | null;
+  login: (user: User) => void;
+  logout: () => void;
+}
+
+const createAuthSlice: StateCreator<AuthSlice & CartSlice, [], [], AuthSlice> = set => ({
+  user: null,
+  login: (user) => set({ user }),
+  logout: () => set({ user: null }),
+});
+
+// Slice 2: cart
+interface CartSlice {
+  items: CartItem[];
+  addItem: (item: CartItem) => void;
+  removeItem: (id: string) => void;
+}
+
+const createCartSlice: StateCreator<AuthSlice & CartSlice, [], [], CartSlice> = set => ({
+  items: [],
+  addItem: (item) => set(state => ({ items: [...state.items, item] })),
+  removeItem: (id) => set(state => ({ items: state.items.filter(i => i.id !== id) })),
+});
+
+// Combined store
+const useStore = create<AuthSlice & CartSlice>()((...args) => ({
+  ...createAuthSlice(...args),
+  ...createCartSlice(...args),
+}));
+
+// Focused selectors — each component subscribes to only its slice
+export const useUser = () => useStore(state => state.user);
+export const useCart = () => useStore(state => state.items);
+export const useCartActions = () =>
+  useStore(state => ({ addItem: state.addItem, removeItem: state.removeItem }));
+```
+
+---
+
+## Jotai
+
+Atomic state model — compose fine-grained atoms instead of one store.
+
+```typescript
+import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
+import { atomWithStorage, atomWithReset } from 'jotai/utils';
+
+// Primitive atoms
+const countAtom = atom(0);
+const nameAtom = atom('');
+
+// Derived (read-only) atom
+const doubledAtom = atom(get => get(countAtom) * 2);
+
+// Write-only atom
+const incrementAtom = atom(null, (get, set) => {
+  set(countAtom, get(countAtom) + 1);
+});
+
+// Async atom — integrates with Suspense
+const userAtom = atom(async () => {
+  const res = await fetch('/api/me');
+  return res.json() as Promise<User>;
+});
+
+// Persistent atom (localStorage)
+const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light');
+
+// Resettable atom
+const filterAtom = atomWithReset({ search: '', category: 'all' });
+
+// Usage
+function Counter() {
+  const [count, setCount] = useAtom(countAtom);
+  const doubled = useAtomValue(doubledAtom);
+  const increment = useSetAtom(incrementAtom);
+
+  return (
+    <div>
+      <p>Count: {count}, Doubled: {doubled}</p>
+      <button onClick={increment}>Increment</button>
+      <button onClick={() => setCount(0)}>Reset</button>
+    </div>
+  );
+}
+```
+
+---
+
+## Redux Toolkit
+
+Best for large teams, complex state machines, and when time-travel debugging matters.
+
+### Slice + Thunk
+
+```typescript
+import {
+  createSlice,
+  createAsyncThunk,
+  PayloadAction,
+  createEntityAdapter,
+} from '@reduxjs/toolkit';
+import type { RootState, AppDispatch } from './store';
+
+// Entity adapter for normalized CRUD
+const usersAdapter = createEntityAdapter<User>();
+
+// Async thunk for data fetching
+export const fetchUsers = createAsyncThunk(
+  'users/fetchAll',
+  async (_, { rejectWithValue }) => {
+    try {
+      const res = await fetch('/api/users');
+      if (!res.ok) throw new Error(`HTTP ${res.status}`);
+      return (await res.json()) as User[];
+    } catch (err) {
+      return rejectWithValue((err as Error).message);
+    }
+  }
+);
+
+// Slice
+const usersSlice = createSlice({
+  name: 'users',
+  initialState: usersAdapter.getInitialState({
+    status: 'idle' as 'idle' | 'loading' | 'succeeded' | 'failed',
+    error: null as string | null,
+  }),
+  reducers: {
+    userAdded: usersAdapter.addOne,
+    userUpdated: usersAdapter.updateOne,
+    userRemoved: usersAdapter.removeOne,
+  },
+  extraReducers: builder => {
+    builder
+      .addCase(fetchUsers.pending, state => {
+        state.status = 'loading';
+      })
+      .addCase(fetchUsers.fulfilled, (state, action) => {
+        state.status = 'succeeded';
+        usersAdapter.setAll(state, action.payload);
+      })
+      .addCase(fetchUsers.rejected, (state, action) => {
+        state.status = 'failed';
+        state.error = action.payload as string;
+      });
+  },
+});
+
+// Selectors from adapter
+export const { selectAll: selectAllUsers, selectById: selectUserById } =
+  usersAdapter.getSelectors((state: RootState) => state.users);
+
+// Custom selectors
+export const selectUsersStatus = (state: RootState) => state.users.status;
+
+export const { userAdded, userUpdated, userRemoved } = usersSlice.actions;
+export default usersSlice.reducer;
+```
+
+### RTK Query (preferred over sagas/thunks for data fetching)
+
+```typescript
+import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
+
+export const apiSlice = createApi({
+  reducerPath: 'api',
+  baseQuery: fetchBaseQuery({
+    baseUrl: '/api',
+    prepareHeaders: (headers, { getState }) => {
+      const token = (getState() as RootState).auth.token;
+      if (token) headers.set('Authorization', `Bearer ${token}`);
+      return headers;
+    },
+  }),
+  tagTypes: ['User', 'Post'],
+  endpoints: builder => ({
+    getUsers: builder.query<User[], void>({
+      query: () => '/users',
+      providesTags: ['User'],
+    }),
+    getUserById: builder.query<User, string>({
+      query: id => `/users/${id}`,
+      providesTags: (result, error, id) => [{ type: 'User', id }],
+    }),
+    createUser: builder.mutation<User, Partial<User>>({
+      query: body => ({ url: '/users', method: 'POST', body }),
+      invalidatesTags: ['User'],
+    }),
+    updateUser: builder.mutation<User, Pick<User, 'id'> & Partial<User>>({
+      query: ({ id, ...patch }) => ({ url: `/users/${id}`, method: 'PATCH', body: patch }),
+      invalidatesTags: (result, error, { id }) => [{ type: 'User', id }],
+    }),
+  }),
+});
+
+export const {
+  useGetUsersQuery,
+  useGetUserByIdQuery,
+  useCreateUserMutation,
+  useUpdateUserMutation,
+} = apiSlice;
+
+// Usage
+function UserList() {
+  const { data: users = [], isLoading, isError } = useGetUsersQuery();
+  const [createUser, { isLoading: isCreating }] = useCreateUserMutation();
+
+  if (isLoading) return <Spinner />;
+  if (isError) return <Error />;
+
+  return (
+    <>
+      {users.map(u => <UserCard key={u.id} user={u} />)}
+      <button onClick={() => createUser({ name: 'New User' })} disabled={isCreating}>
+        Add User
+      </button>
+    </>
+  );
+}
+```
+
+---
+
+## TanStack Query (React Query)
+
+The standard for server state. Handles caching, background refetch, stale-while-revalidate.
+
+```typescript
+import {
+  useQuery,
+  useMutation,
+  useQueryClient,
+  useInfiniteQuery,
+  QueryClient,
+  QueryClientProvider,
+} from '@tanstack/react-query';
+
+// Setup
+const queryClient = new QueryClient({
+  defaultOptions: {
+    queries: {
+      staleTime: 60 * 1000, // data fresh for 1 minute
+      retry: 3,
+    },
+  },
+});
+
+function App() {
+  return (
+    <QueryClientProvider client={queryClient}>
+      <Router />
+    </QueryClientProvider>
+  );
+}
+
+// Basic query
+function UserProfile({ userId }: { userId: string }) {
+  const { data: user, isLoading, error, refetch } = useQuery({
+    queryKey: ['users', userId],
+    queryFn: () => fetchUser(userId),
+    enabled: !!userId, // only run when userId is truthy
+    staleTime: 5 * 60 * 1000, // override: 5 minutes
+  });
+
+  if (isLoading) return <Skeleton />;
+  if (error) return <Error onRetry={refetch} />;
+  return <div>{user?.name}</div>;
+}
+
+// Mutation with optimistic update
+function DeleteButton({ userId }: { userId: string }) {
+  const queryClient = useQueryClient();
+
+  const mutation = useMutation({
+    mutationFn: (id: string) => deleteUser(id),
+
+    // Optimistic update
+    onMutate: async (id) => {
+      await queryClient.cancelQueries({ queryKey: ['users'] });
+      const previousUsers = queryClient.getQueryData<User[]>(['users']);
+
+      queryClient.setQueryData<User[]>(['users'], old =>
+        old?.filter(u => u.id !== id) ?? []
+      );
+
+      return { previousUsers }; // context for rollback
+    },
+
+    // Rollback on error
+    onError: (err, id, context) => {
+      if (context?.previousUsers) {
+        queryClient.setQueryData(['users'], context.previousUsers);
+      }
+    },
+
+    // Always invalidate after settle
+    onSettled: () => {
+      queryClient.invalidateQueries({ queryKey: ['users'] });
+    },
+  });
+
+  return (
+    <button
+      onClick={() => mutation.mutate(userId)}
+      disabled={mutation.isPending}
+    >
+      {mutation.isPending ? 'Deleting...' : 'Delete'}
+    </button>
+  );
+}
+
+// Infinite query (pagination / infinite scroll)
+function PostFeed() {
+  const {
+    data,
+    fetchNextPage,
+    hasNextPage,
+    isFetchingNextPage,
+  } = useInfiniteQuery({
+    queryKey: ['posts'],
+    queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam, limit: 20 }),
+    initialPageParam: undefined as string | undefined,
+    getNextPageParam: lastPage => lastPage.nextCursor,
+  });
+
+  const posts = data?.pages.flatMap(page => page.posts) ?? [];
+
+  return (
+    <>
+      {posts.map(post => <PostCard key={post.id} post={post} />)}
+      <button
+        onClick={() => fetchNextPage()}
+        disabled={!hasNextPage || isFetchingNextPage}
+      >
+        {isFetchingNextPage ? 'Loading...' : 'Load More'}
+      </button>
+    </>
+  );
+}
+
+// Prefetch on hover (instant navigation feel)
+function PostLink({ postId }: { postId: string }) {
+  const queryClient = useQueryClient();
+  return (
+    <a
+      href={`/posts/${postId}`}
+      onMouseEnter={() => queryClient.prefetchQuery({
+        queryKey: ['posts', postId],
+        queryFn: () => fetchPost(postId),
+      })}
+    >
+      Read More
+    </a>
+  );
+}
+```
+
+---
+
+## React Hook Form
+
+```typescript
+import { useForm, Controller, SubmitHandler } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { z } from 'zod';
+
+// 1. Define schema with Zod
+const profileSchema = z.object({
+  name: z.string().min(2, 'Name must be at least 2 characters'),
+  email: z.string().email('Invalid email address'),
+  age: z.coerce.number().int().min(18, 'Must be at least 18').max(120),
+  role: z.enum(['admin', 'user', 'moderator']),
+  bio: z.string().max(500).optional(),
+});
+
+type ProfileForm = z.infer<typeof profileSchema>;
+
+// 2. Form component
+function ProfileForm({ onSave }: { onSave: (data: ProfileForm) => Promise<void> }) {
+  const {
+    register,
+    handleSubmit,
+    control,
+    formState: { errors, isSubmitting, isDirty },
+    reset,
+  } = useForm<ProfileForm>({
+    resolver: zodResolver(profileSchema),
+    defaultValues: { name: '', email: '', age: 18, role: 'user' },
+  });
+
+  const onSubmit: SubmitHandler<ProfileForm> = async data => {
+    await onSave(data);
+    reset(); // reset to defaultValues after success
+  };
+
+  return (
+    <form onSubmit={handleSubmit(onSubmit)} noValidate>
+      <div>
+        <label htmlFor="name">Name</label>
+        <input
+          id="name"
+          {...register('name')}
+          aria-describedby={errors.name ? 'name-error' : undefined}
+          aria-invalid={!!errors.name}
+        />
+        {errors.name && (
+          <span id="name-error" role="alert">{errors.name.message}</span>
+        )}
+      </div>
+
+      {/* Controller for third-party input components */}
+      <Controller
+        name="role"
+        control={control}
+        render={({ field }) => (
+          <Select
+            value={field.value}
+            onChange={field.onChange}
+            options={['admin', 'user', 'moderator']}
+          />
+        )}
+      />
+
+      <button type="submit" disabled={isSubmitting || !isDirty}>
+        {isSubmitting ? 'Saving...' : 'Save Profile'}
+      </button>
+    </form>
+  );
+}
+```
+
+---
+
+## URL State
+
+```typescript
+import { useSearchParams } from 'react-router-dom';
+
+// Built-in useSearchParams (React Router v6)
+function ProductFilters() {
+  const [searchParams, setSearchParams] = useSearchParams();
+
+  const category = searchParams.get('category') ?? 'all';
+  const page = Number(searchParams.get('page') ?? '1');
+
+  const setFilter = (key: string, value: string) => {
+    setSearchParams(prev => {
+      prev.set(key, value);
+      if (key !== 'page') prev.set('page', '1'); // reset page on filter change
+      return prev;
+    });
+  };
+
+  return (
+    <div>
+      <select
+        value={category}
+        onChange={e => setFilter('category', e.target.value)}
+      >
+        <option value="all">All</option>
+        <option value="shoes">Shoes</option>
+      </select>
+      <Pagination currentPage={page} onPageChange={p => setFilter('page', String(p))} />
+    </div>
+  );
+}
+
+// nuqs — type-safe URL state (Next.js or any framework)
+// npm install nuqs
+import { useQueryState, parseAsInteger, parseAsString } from 'nuqs';
+
+function FilterPage() {
+  const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
+  const [search, setSearch] = useQueryState('q', parseAsString.withDefault(''));
+
+  return (
+    <div>
+      <input value={search} onChange={e => setSearch(e.target.value)} />
+      <button onClick={() => setPage(p => p + 1)}>Next page ({page})</button>
+    </div>
+  );
+}
+```
+
+---
+
+## Anti-patterns
+
+| Anti-pattern | Problem | Fix |
+|--------------|---------|-----|
+| `useState` + `useEffect` for server data | Manual loading/error/cache management, stale data | TanStack Query |
+| Single massive Context for all global state | Every consumer re-renders on any change | Split contexts by update frequency |
+| Putting functions in Context value without `useMemo` | New object reference every render | `useMemo` the value |
+| Zustand selectors that return objects | New object reference every call triggers re-render | Select primitives; or use `shallow` equality: `useStore(state => state.items, shallow)` |
+| `useEffect` to sync two pieces of state | Double render, complexity | Derive state during render or use `useReducer` |
+| Redux for everything including server data | Over-normalized, async complexity | RTK Query or TanStack Query for server state |
+| No staleTime in TanStack Query | Constant background refetches on every mount | Set appropriate `staleTime` per query |

+ 519 - 0
skills/react-ops/references/testing.md

@@ -0,0 +1,519 @@
+# Testing
+
+React Testing Library, user-event, MSW, Vitest setup, hook testing, and accessibility testing.
+
+---
+
+## Philosophy
+
+Test behavior, not implementation. Tests should resemble how users interact with your app.
+
+- Query by what the user sees (role, label, text) — not by class names or IDs
+- Interact the way users do (click, type, submit) — not by calling component methods
+- Assert what the user sees as the outcome — not component state
+
+---
+
+## Vitest + RTL Setup
+
+```bash
+npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
+```
+
+```typescript
+// vitest.config.ts
+import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+  plugins: [react()],
+  test: {
+    environment: 'jsdom',
+    globals: true,           // no import { describe, it, expect } needed
+    setupFiles: ['./src/test/setup.ts'],
+    coverage: {
+      provider: 'v8',
+      reporter: ['text', 'lcov'],
+      exclude: ['**/*.stories.tsx', '**/index.ts'],
+    },
+  },
+});
+```
+
+```typescript
+// src/test/setup.ts
+import '@testing-library/jest-dom'; // extends expect with DOM matchers
+import { cleanup } from '@testing-library/react';
+import { afterEach, beforeAll, afterAll } from 'vitest';
+import { server } from './mocks/server';
+
+// RTL cleanup after each test
+afterEach(() => cleanup());
+
+// MSW lifecycle
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+```
+
+---
+
+## Rendering and Queries
+
+### Query Priority
+
+```
+1. getByRole         — semantic HTML, accessible name
+2. getByLabelText    — form labels
+3. getByPlaceholderText — input placeholders (prefer label)
+4. getByText         — visible text content
+5. getByDisplayValue — current input value
+6. getByAltText      — img alt text
+7. getByTitle        — title attribute
+8. getByTestId       — last resort: data-testid="..."
+```
+
+### getBy vs queryBy vs findBy
+
+| Variant | Returns | Throws | Async |
+|---------|---------|--------|-------|
+| `getBy*` | Element | If not found | No |
+| `queryBy*` | Element or null | No | No |
+| `findBy*` | Promise<Element> | If timeout | Yes |
+| `getAllBy*` | Element[] | If none found | No |
+| `queryAllBy*` | Element[] | No | No |
+| `findAllBy*` | Promise<Element[]> | If timeout | Yes |
+
+```tsx
+import { render, screen } from '@testing-library/react';
+
+test('renders user profile', () => {
+  render(<UserProfile user={{ name: 'Alice', role: 'admin' }} />);
+
+  // Role-based (preferred) — uses ARIA roles
+  expect(screen.getByRole('heading', { name: 'Alice' })).toBeInTheDocument();
+  expect(screen.getByRole('button', { name: /edit profile/i })).toBeEnabled();
+
+  // Text content
+  expect(screen.getByText('admin')).toBeInTheDocument();
+
+  // For content that should NOT be present
+  expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
+});
+```
+
+---
+
+## User Interactions
+
+Always use `@testing-library/user-event` over `fireEvent` — it simulates real browser events including pointer events, focus, keyboard navigation.
+
+```tsx
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+describe('LoginForm', () => {
+  // Create user instance once per test — manages pointer state
+  const user = userEvent.setup();
+
+  test('submits with valid credentials', async () => {
+    const onLogin = vi.fn();
+    render(<LoginForm onLogin={onLogin} />);
+
+    // Type into inputs (fires focus, input, change, keydown/up events)
+    await user.type(screen.getByLabelText(/email/i), 'alice@example.com');
+    await user.type(screen.getByLabelText(/password/i), 'password123');
+
+    // Click submit
+    await user.click(screen.getByRole('button', { name: /sign in/i }));
+
+    expect(onLogin).toHaveBeenCalledWith({
+      email: 'alice@example.com',
+      password: 'password123',
+    });
+  });
+
+  test('shows validation error for empty email', async () => {
+    render(<LoginForm onLogin={vi.fn()} />);
+
+    // Tab to trigger blur validation without typing
+    await user.click(screen.getByLabelText(/email/i));
+    await user.tab();
+
+    expect(screen.getByRole('alert')).toHaveTextContent(/email is required/i);
+  });
+
+  test('disables submit while loading', async () => {
+    render(<LoginForm onLogin={() => new Promise(() => {})} />); // never resolves
+
+    await user.type(screen.getByLabelText(/email/i), 'alice@example.com');
+    await user.type(screen.getByLabelText(/password/i), 'pass');
+    await user.click(screen.getByRole('button', { name: /sign in/i }));
+
+    expect(screen.getByRole('button', { name: /signing in/i })).toBeDisabled();
+  });
+});
+```
+
+### Select, Keyboard, Upload
+
+```tsx
+// Select dropdown
+await user.selectOptions(screen.getByRole('combobox', { name: /country/i }), 'Canada');
+expect(screen.getByRole('option', { name: 'Canada' })).toBeSelected();
+
+// Keyboard shortcuts
+await user.keyboard('{Escape}');          // press Escape
+await user.keyboard('{Control>}k{/Control}'); // Ctrl+K
+
+// File upload
+const file = new File(['content'], 'test.pdf', { type: 'application/pdf' });
+await user.upload(screen.getByLabelText(/upload/i), file);
+
+// Clear an input
+await user.clear(screen.getByRole('textbox', { name: /search/i }));
+```
+
+---
+
+## Async Testing
+
+```tsx
+import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
+
+test('loads and displays users', async () => {
+  render(<UserList />);
+
+  // Assert loading state
+  expect(screen.getByRole('status')).toHaveTextContent(/loading/i);
+
+  // Wait for async operation to complete
+  await waitFor(() => {
+    expect(screen.queryByRole('status')).not.toBeInTheDocument();
+  });
+
+  // Alternatively: wait for element to disappear
+  await waitForElementToBeRemoved(() => screen.queryByRole('status'));
+
+  // Assert loaded state
+  expect(screen.getByRole('list')).toBeInTheDocument();
+  expect(screen.getAllByRole('listitem')).toHaveLength(3);
+});
+
+// findBy* — combines waitFor + getBy
+test('shows error on failed load', async () => {
+  server.use(
+    http.get('/api/users', () => HttpResponse.error())
+  );
+
+  render(<UserList />);
+
+  // findBy waits up to 1000ms by default
+  const error = await screen.findByRole('alert');
+  expect(error).toHaveTextContent(/failed to load/i);
+});
+```
+
+---
+
+## Custom Render with Providers
+
+```tsx
+// src/test/utils.tsx
+import { render, RenderOptions } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter } from 'react-router-dom';
+import { ReactNode } from 'react';
+
+interface WrapperOptions {
+  initialRoute?: string;
+}
+
+function createWrapper({ initialRoute = '/' }: WrapperOptions = {}) {
+  const queryClient = new QueryClient({
+    defaultOptions: {
+      queries: { retry: false },    // no retries in tests
+      mutations: { retry: false },
+    },
+  });
+
+  return function Wrapper({ children }: { children: ReactNode }) {
+    return (
+      <QueryClientProvider client={queryClient}>
+        <MemoryRouter initialEntries={[initialRoute]}>
+          {children}
+        </MemoryRouter>
+      </QueryClientProvider>
+    );
+  };
+}
+
+// Custom render — drop-in replacement for RTL's render
+function customRender(
+  ui: React.ReactElement,
+  options: WrapperOptions & Omit<RenderOptions, 'wrapper'> = {}
+) {
+  const { initialRoute, ...renderOptions } = options;
+  return render(ui, {
+    wrapper: createWrapper({ initialRoute }),
+    ...renderOptions,
+  });
+}
+
+// Re-export everything from RTL so tests only need to import from here
+export * from '@testing-library/react';
+export { customRender as render };
+```
+
+```tsx
+// Usage in tests — exact same API as RTL
+import { render, screen } from '../test/utils';
+
+test('navigates to profile', async () => {
+  const user = userEvent.setup();
+  render(<App />, { initialRoute: '/dashboard' });
+
+  await user.click(screen.getByRole('link', { name: /profile/i }));
+  expect(screen.getByRole('heading', { name: /your profile/i })).toBeInTheDocument();
+});
+```
+
+---
+
+## MSW (Mock Service Worker)
+
+MSW intercepts real network requests — no mocking of fetch/axios needed.
+
+```typescript
+// src/test/mocks/handlers.ts
+import { http, HttpResponse } from 'msw';
+
+const mockUsers: User[] = [
+  { id: '1', name: 'Alice', email: 'alice@example.com' },
+  { id: '2', name: 'Bob', email: 'bob@example.com' },
+];
+
+export const handlers = [
+  // GET /api/users
+  http.get('/api/users', () => {
+    return HttpResponse.json(mockUsers);
+  }),
+
+  // GET /api/users/:id
+  http.get('/api/users/:id', ({ params }) => {
+    const user = mockUsers.find(u => u.id === params.id);
+    if (!user) return new HttpResponse(null, { status: 404 });
+    return HttpResponse.json(user);
+  }),
+
+  // POST /api/users
+  http.post('/api/users', async ({ request }) => {
+    const body = await request.json() as Partial<User>;
+    const newUser = { id: crypto.randomUUID(), ...body } as User;
+    return HttpResponse.json(newUser, { status: 201 });
+  }),
+
+  // DELETE /api/users/:id
+  http.delete('/api/users/:id', ({ params }) => {
+    return new HttpResponse(null, { status: 204 });
+  }),
+];
+```
+
+```typescript
+// src/test/mocks/server.ts
+import { setupServer } from 'msw/node';
+import { handlers } from './handlers';
+
+export const server = setupServer(...handlers);
+```
+
+```tsx
+// Override handlers in specific tests
+import { server } from '../test/mocks/server';
+import { http, HttpResponse } from 'msw';
+
+test('shows error when API fails', async () => {
+  // Override default handler for this test only
+  server.use(
+    http.get('/api/users', () => {
+      return HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 });
+    })
+  );
+
+  render(<UserList />);
+  await screen.findByRole('alert');
+  expect(screen.getByRole('alert')).toHaveTextContent(/something went wrong/i);
+});
+```
+
+---
+
+## Hook Testing
+
+```tsx
+import { renderHook, act } from '@testing-library/react';
+import { useCounter } from './useCounter';
+
+test('useCounter increments correctly', () => {
+  const { result } = renderHook(() => useCounter(0));
+
+  expect(result.current.count).toBe(0);
+
+  act(() => result.current.increment());
+  expect(result.current.count).toBe(1);
+
+  act(() => result.current.incrementBy(5));
+  expect(result.current.count).toBe(6);
+
+  act(() => result.current.reset());
+  expect(result.current.count).toBe(0);
+});
+
+// Test hooks that use context
+test('useTheme reads from ThemeProvider', () => {
+  const wrapper = ({ children }: { children: React.ReactNode }) => (
+    <ThemeProvider initialTheme="dark">{children}</ThemeProvider>
+  );
+
+  const { result } = renderHook(() => useTheme(), { wrapper });
+  expect(result.current.theme).toBe('dark');
+
+  act(() => result.current.toggleTheme());
+  expect(result.current.theme).toBe('light');
+});
+
+// Test async hooks
+test('useFetch returns data', async () => {
+  const { result } = renderHook(() => useFetch<User[]>('/api/users'));
+
+  expect(result.current.isLoading).toBe(true);
+
+  await waitFor(() => {
+    expect(result.current.isLoading).toBe(false);
+  });
+
+  expect(result.current.data).toHaveLength(2);
+  expect(result.current.error).toBeNull();
+});
+```
+
+---
+
+## Component Testing Patterns
+
+### Modal
+
+```tsx
+test('modal opens and closes', async () => {
+  const user = userEvent.setup();
+  render(<DeleteConfirmation onDelete={vi.fn()} />);
+
+  // Modal should not be in DOM initially
+  expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+
+  await user.click(screen.getByRole('button', { name: /delete/i }));
+  expect(screen.getByRole('dialog')).toBeInTheDocument();
+  expect(screen.getByRole('dialog')).toHaveAccessibleName(/confirm deletion/i);
+
+  await user.click(screen.getByRole('button', { name: /cancel/i }));
+  await waitForElementToBeRemoved(() => screen.queryByRole('dialog'));
+});
+```
+
+### Form Validation
+
+```tsx
+test('validates required fields on submit', async () => {
+  const user = userEvent.setup();
+  const onSubmit = vi.fn();
+  render(<ContactForm onSubmit={onSubmit} />);
+
+  // Submit empty form
+  await user.click(screen.getByRole('button', { name: /submit/i }));
+
+  // Errors appear
+  expect(screen.getByText(/name is required/i)).toBeInTheDocument();
+  expect(screen.getByText(/email is required/i)).toBeInTheDocument();
+
+  // Form was not submitted
+  expect(onSubmit).not.toHaveBeenCalled();
+});
+```
+
+---
+
+## Accessibility Testing
+
+```tsx
+import { axe, toHaveNoViolations } from 'jest-axe';
+
+expect.extend(toHaveNoViolations());
+
+test('has no accessibility violations', async () => {
+  const { container } = render(<LoginForm onLogin={vi.fn()} />);
+  const results = await axe(container);
+  expect(results).toHaveNoViolations();
+});
+
+// Test keyboard navigation
+test('modal is keyboard accessible', async () => {
+  const user = userEvent.setup();
+  render(<Modal isOpen onClose={vi.fn()} title="Confirm">Content</Modal>);
+
+  const dialog = screen.getByRole('dialog');
+
+  // Dialog should have correct ARIA attributes
+  expect(dialog).toHaveAttribute('aria-modal', 'true');
+  expect(dialog).toHaveAccessibleName('Confirm');
+
+  // Escape closes modal
+  await user.keyboard('{Escape}');
+  // ... assert closed
+});
+
+// Test screen reader text
+test('icon button has accessible name', () => {
+  render(<button aria-label="Close menu"><XIcon /></button>);
+  expect(screen.getByRole('button', { name: /close menu/i })).toBeInTheDocument();
+});
+```
+
+---
+
+## Snapshot Testing
+
+Use sparingly — for stable UI components where visual regression is more important than behavior.
+
+```tsx
+// PREFER behavioral tests over snapshots
+// Use snapshots only for:
+// - Stable design system components (Button, Badge, Avatar)
+// - Complex SVG/icon output
+// - Error messages with specific formatting
+
+import { render } from '@testing-library/react';
+
+test('Badge renders correctly', () => {
+  const { container } = render(<Badge variant="success" count={5} />);
+  expect(container.firstChild).toMatchSnapshot();
+});
+
+// Update snapshots when intentional changes are made:
+// vitest --update-snapshots
+```
+
+---
+
+## Anti-patterns
+
+| Anti-pattern | Problem | Fix |
+|--------------|---------|-----|
+| Query by CSS class or id | Brittle, implementation detail | Query by role, label, or text |
+| `fireEvent` instead of `userEvent` | Doesn't fire real browser events | Use `@testing-library/user-event` |
+| Testing internal state | Tests break on refactor | Test rendered output and behavior |
+| Mocking React components | Hides integration bugs | Test with real components; mock network instead |
+| No async awaiting | Tests pass before assertions run | Always `await` user interactions and async queries |
+| `data-testid` as first choice | Couples tests to implementation | Last resort after semantic queries fail |
+| Test per implementation detail | Brittle test suite | Test per user story / behavior |
+| No error case tests | Only happy path covered | Test loading, error, empty, and edge states |

+ 0 - 0
skills/react-ops/scripts/.gitkeep


+ 425 - 61
skills/tailwind-ops/SKILL.md

@@ -1,109 +1,473 @@
 ---
 name: tailwind-ops
-description: "Quick reference for Tailwind CSS utility patterns, responsive design, and configuration. Triggers on: tailwind, utility classes, responsive design, tailwind config, dark mode css, tw classes."
-compatibility: "For projects using Tailwind CSS v3+."
-allowed-tools: "Read Write"
+description: "Tailwind CSS utility patterns, responsive design, component patterns, v4 migration, and configuration. Use for: tailwind, tailwindcss, utility classes, responsive design, dark mode, tailwind v4, tailwind config, tw, container queries, @apply, prose, typography, animation."
+allowed-tools: "Read Write Bash"
+related-skills: [react-ops, vue-ops, astro-ops]
 ---
 
-# Tailwind Patterns
+# Tailwind Operations
 
-Quick reference for Tailwind CSS utility patterns.
+Comprehensive Tailwind CSS patterns covering layout, responsive design, components, dark mode, animations, and v4 migration.
 
-## Responsive Breakpoints
+## Layout Decision Tree
 
-| Prefix | Min Width |
-|--------|-----------|
-| `sm:` | 640px |
-| `md:` | 768px |
-| `lg:` | 1024px |
-| `xl:` | 1280px |
-| `2xl:` | 1536px |
+```
+Which layout approach?
+│
+├─ Items in a single row or column?
+│  └─ Use Flexbox
+│     ├─ Row:    class="flex items-center gap-4"
+│     ├─ Column: class="flex flex-col gap-4"
+│     ├─ Wrap:   class="flex flex-wrap gap-4"
+│     └─ Push item to end: class="flex" + child class="ml-auto"
+│
+├─ Items in a 2D grid (rows AND columns)?
+│  └─ Use CSS Grid
+│     ├─ Equal columns:   class="grid grid-cols-3 gap-6"
+│     ├─ Responsive grid:  class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
+│     ├─ Sidebar layout:  class="grid grid-cols-[250px_1fr] gap-6"
+│     ├─ Spanning:         child class="col-span-2" or "row-span-2"
+│     └─ Auto-fill:        class="grid grid-cols-[repeat(auto-fill,minmax(250px,1fr))] gap-6"
+│
+├─ Component should adapt to its CONTAINER size (not viewport)?
+│  └─ Use Container Queries (v3.2+ / v4 native)
+│     ├─ Parent:  class="@container"
+│     ├─ Child:   class="@sm:flex-row @lg:grid-cols-3"
+│     └─ Named:   class="@container/sidebar" → child: "@sm/sidebar:flex-row"
+│
+├─ Centering something?
+│  ├─ Horizontal text:  class="text-center"
+│  ├─ Horizontal block: class="mx-auto" (needs width)
+│  ├─ Flex center:      class="flex items-center justify-center"
+│  ├─ Grid center:      class="grid place-items-center"
+│  └─ Absolute center:  class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"
+│
+└─ Full-page layout (header/sidebar/content/footer)?
+   └─ Use Grid with named areas or template rows
+      ├─ Sticky header:  class="grid grid-rows-[auto_1fr_auto] min-h-screen"
+      └─ Sidebar + main: class="grid grid-cols-[250px_1fr] min-h-screen"
+```
+
+## Responsive Design Quick Reference
+
+### Breakpoints (Mobile-First)
+
+| Prefix | Min Width | Typical Target |
+|--------|-----------|----------------|
+| _(none)_ | 0px | Mobile (default) |
+| `sm:` | 640px | Large phones, landscape |
+| `md:` | 768px | Tablets |
+| `lg:` | 1024px | Small laptops |
+| `xl:` | 1280px | Desktops |
+| `2xl:` | 1536px | Large screens |
+
+**Mobile-first means**: base styles apply to mobile, add breakpoint prefixes to override upward.
 
 ```html
-<div class="w-full md:w-1/2 lg:w-1/3">
-  <!-- Full on mobile, half on tablet, third on desktop -->
+<!-- Stack on mobile, 2 columns on tablet, 3 on desktop -->
+<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
+  <div>Card 1</div>
+  <div>Card 2</div>
+  <div>Card 3</div>
 </div>
+
+<!-- Hide on mobile, show on desktop -->
+<nav class="hidden lg:flex items-center gap-6">...</nav>
+
+<!-- Full width on mobile, constrained on desktop -->
+<div class="w-full max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">...</div>
 ```
 
-## Common Layout Patterns
+### Container Queries
 
 ```html
-<!-- Centered container -->
-<div class="container mx-auto px-4">
+<!-- Parent declares itself as a container -->
+<div class="@container">
+  <!-- Children respond to PARENT width, not viewport -->
+  <div class="flex flex-col @sm:flex-row @lg:grid @lg:grid-cols-3 gap-4">
+    <div>Adapts to container</div>
+  </div>
+</div>
 
-<!-- Flexbox row -->
-<div class="flex items-center justify-between gap-4">
+<!-- Named container (useful when nesting) -->
+<div class="@container/card">
+  <h2 class="text-sm @md/card:text-lg">Responds to card container</h2>
+</div>
+```
 
-<!-- Grid -->
-<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
+### Fluid Typography with clamp()
+
+```html
+<!-- Fluid heading: 1.5rem at small, 3rem at large, scales between -->
+<h1 class="text-[clamp(1.5rem,4vw,3rem)]">Fluid Heading</h1>
 
-<!-- Stack -->
-<div class="flex flex-col gap-4">
+<!-- Fluid body text -->
+<p class="text-[clamp(0.875rem,1.5vw,1.125rem)] leading-relaxed">
+  Body text that scales smoothly.
+</p>
 ```
 
-## Card
+## Dark Mode Decision Tree
+
+```
+Which dark mode strategy?
+│
+├─ Manual toggle (user preference stored)?
+│  └─ class strategy (v3) / selector strategy (v4)
+│
+│     v3: tailwind.config.js
+│     module.exports = { darkMode: 'class' }
+│     → Add class="dark" to <html> element
+│
+│     v4: CSS @custom-variant or default behavior
+│     @custom-variant dark (&:where(.dark, .dark *));
+│     → Same toggle, add class="dark" to <html>
+│
+├─ Follow system preference only?
+│  └─ media strategy
+│
+│     v3: tailwind.config.js
+│     module.exports = { darkMode: 'media' }
+│     → Uses prefers-color-scheme automatically
+│
+│     v4: Default behavior (no config needed)
+│     → Uses prefers-color-scheme out of the box
+│
+└─ Custom selector (data attribute, etc.)?
+   └─ selector strategy (v4 only)
+
+      v4: @custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
+      → Add data-theme="dark" to <html>
+```
+
+### Dark Mode Patterns
 
 ```html
-<div class="bg-white rounded-lg shadow-md p-6">
-  <h3 class="text-lg font-semibold mb-2">Title</h3>
-  <p class="text-gray-600">Content</p>
+<!-- Background and text -->
+<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
+
+  <!-- Card with dark variant -->
+  <div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
+    <h3 class="text-gray-900 dark:text-white font-semibold">Card Title</h3>
+    <p class="text-gray-600 dark:text-gray-400">Card content adapts to dark mode.</p>
+  </div>
+
+  <!-- Input with dark variant -->
+  <input type="text"
+    class="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600
+           text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500
+           focus:ring-2 focus:ring-blue-500 rounded-lg px-4 py-2"
+    placeholder="Type here...">
 </div>
 ```
 
-## Button
+## Component Patterns Quick Reference
 
 ```html
-<button class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">
-  Button
-</button>
+<!-- Card -->
+<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
+  <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Title</h3>
+  <p class="text-gray-600 dark:text-gray-400">Content here.</p>
+</div>
+
+<!-- Button variants -->
+<button class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 transition-colors">Primary</button>
+<button class="bg-gray-200 text-gray-800 px-4 py-2 rounded-lg hover:bg-gray-300 transition-colors">Secondary</button>
+<button class="border border-gray-300 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors">Outline</button>
+<button class="text-blue-600 px-4 py-2 rounded-lg hover:bg-blue-50 transition-colors">Ghost</button>
+
+<!-- Form input -->
+<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
+<input type="email"
+  class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
+         bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100
+         focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+  placeholder="you@example.com">
+
+<!-- Navbar -->
+<nav class="bg-white dark:bg-gray-900 shadow">
+  <div class="max-w-7xl mx-auto px-4 flex items-center justify-between h-16">
+    <a href="/" class="text-xl font-bold text-gray-900 dark:text-white">Logo</a>
+    <div class="hidden md:flex items-center gap-6">
+      <a href="#" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">Home</a>
+      <a href="#" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700">CTA</a>
+    </div>
+  </div>
+</nav>
+
+<!-- Modal overlay -->
+<div class="fixed inset-0 z-50 flex items-center justify-center">
+  <div class="fixed inset-0 bg-black/50" aria-hidden="true"></div>
+  <div class="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6 w-full max-w-md mx-4" role="dialog" aria-modal="true">
+    <h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Modal Title</h2>
+    <p class="text-gray-600 dark:text-gray-400 mb-6">Modal content goes here.</p>
+    <div class="flex justify-end gap-3">
+      <button class="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg">Cancel</button>
+      <button class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">Confirm</button>
+    </div>
+  </div>
+</div>
+
+<!-- Badge -->
+<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">Active</span>
+
+<!-- Alert -->
+<div class="flex items-start gap-3 p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800" role="alert">
+  <span class="text-red-600 dark:text-red-400 mt-0.5" aria-hidden="true">&#10007;</span>
+  <div>
+    <h4 class="text-sm font-medium text-red-800 dark:text-red-300">Error</h4>
+    <p class="text-sm text-red-700 dark:text-red-400 mt-1">Something went wrong. Please try again.</p>
+  </div>
+</div>
+```
+
+## Tailwind v4 Quick Reference
+
+### Major Changes from v3
+
+| Area | v3 | v4 |
+|------|----|----|
+| Configuration | `tailwind.config.js` | CSS-first: `@theme` in CSS |
+| Theme values | JS `theme.extend.colors` | `@theme { --color-brand: #3b82f6; }` |
+| Plugins | JS `plugin()` function | `@plugin "my-plugin"` in CSS |
+| Config file | `module.exports = {...}` | `@config "./legacy.config.js"` (compat) |
+| PostCSS | `tailwindcss` package | `@tailwindcss/postcss` |
+| Vite | PostCSS plugin | `@tailwindcss/vite` (faster) |
+| Colors | Named scales (gray-50..950) | Same + OKLCH support |
+| Container queries | Plugin required | Native `@container`, `@sm:`, `@md:` |
+| Entry animations | JS needed | `@starting-style` (CSS native) |
+
+### v4 CSS-First Config
+
+```css
+/* v4: Define theme in CSS */
+@import "tailwindcss";
+
+@theme {
+  --color-brand: #3b82f6;
+  --color-brand-dark: #1d4ed8;
+  --font-display: "Inter", sans-serif;
+  --breakpoint-3xl: 1920px;
+  --spacing-18: 4.5rem;
+}
+
+/* v4: Import a plugin */
+@plugin "@tailwindcss/typography";
+
+/* v4: Use legacy JS config as fallback */
+@config "./tailwind.config.js";
 ```
 
-## Form Input
+### v4 New Utilities
 
 ```html
-<input type="text"
-  class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
-  placeholder="Enter text">
+<!-- Container queries (native in v4) -->
+<div class="@container">
+  <div class="@sm:flex @md:grid @md:grid-cols-2">Adapts to container</div>
+</div>
+
+<!-- @starting-style: entry animations without JS -->
+<!-- Applied via CSS - Tailwind v4 supports it natively -->
+
+<!-- Anchor positioning (experimental) -->
+<!-- Position elements relative to an anchor element via CSS -->
+
+<!-- New shadow and ring defaults -->
+<div class="shadow-sm ring ring-blue-500/20">Improved defaults</div>
 ```
 
-## Dark Mode
+## Animation Patterns
+
+### Transition Utilities
 
 ```html
-<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
+<!-- Color transition (most common) -->
+<button class="bg-blue-600 hover:bg-blue-700 transition-colors duration-150">
+  Hover me
+</button>
+
+<!-- Multiple properties -->
+<div class="transform hover:scale-105 hover:shadow-lg transition-all duration-200 ease-in-out">
+  Scale and shadow on hover
+</div>
+
+<!-- Specific properties -->
+<div class="transition-[transform,opacity] duration-300 ease-out">
+  Only transform and opacity animate
+</div>
 ```
 
+### Built-in Animations
+
+```html
+<!-- Spin (loading spinners) -->
+<svg class="animate-spin h-5 w-5 text-blue-600" viewBox="0 0 24 24">
+  <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
+  <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
+</svg>
+
+<!-- Pulse (skeleton loaders) -->
+<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-4 rounded w-3/4"></div>
+
+<!-- Ping (notification indicator) -->
+<span class="relative flex h-3 w-3">
+  <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
+  <span class="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
+</span>
+
+<!-- Bounce -->
+<div class="animate-bounce">&#8595;</div>
+```
+
+### Custom Keyframes (v3 Config)
+
 ```js
-// tailwind.config.js
-module.exports = { darkMode: 'class' }
+// tailwind.config.js (v3)
+module.exports = {
+  theme: {
+    extend: {
+      keyframes: {
+        'fade-in': {
+          '0%': { opacity: '0', transform: 'translateY(10px)' },
+          '100%': { opacity: '1', transform: 'translateY(0)' },
+        },
+        'slide-in-right': {
+          '0%': { transform: 'translateX(100%)' },
+          '100%': { transform: 'translateX(0)' },
+        },
+      },
+      animation: {
+        'fade-in': 'fade-in 0.3s ease-out',
+        'slide-in-right': 'slide-in-right 0.3s ease-out',
+      },
+    },
+  },
+}
 ```
 
-## State Modifiers
+### Custom Keyframes (v4 CSS)
+
+```css
+/* v4: Define in CSS with @theme */
+@theme {
+  --animate-fade-in: fade-in 0.3s ease-out;
+  --animate-slide-in-right: slide-in-right 0.3s ease-out;
+}
+
+@keyframes fade-in {
+  from { opacity: 0; transform: translateY(10px); }
+  to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes slide-in-right {
+  from { transform: translateX(100%); }
+  to { transform: translateX(0); }
+}
+```
+
+### Entry Animations with @starting-style (v4)
+
+```css
+/* Dialog that animates in from transparent/translated */
+dialog[open] {
+  opacity: 1;
+  transform: translateY(0);
+  transition: opacity 0.3s, transform 0.3s;
 
-| Modifier | Triggers On |
-|----------|-------------|
-| `hover:` | Mouse hover |
-| `focus:` | Element focused |
-| `active:` | Being clicked |
-| `disabled:` | Disabled state |
-| `group-hover:` | Parent hovered |
+  @starting-style {
+    opacity: 0;
+    transform: translateY(10px);
+  }
+}
+```
 
-## Spacing Scale
+## State Modifiers Quick Reference
 
-| Class | Size |
-|-------|------|
-| `p-1` | 4px |
-| `p-2` | 8px |
-| `p-4` | 16px |
-| `p-6` | 24px |
-| `p-8` | 32px |
+| Modifier | Triggers On | Example |
+|----------|-------------|---------|
+| `hover:` | Mouse hover | `hover:bg-blue-700` |
+| `focus:` | Element focused (all focus) | `focus:ring-2` |
+| `focus-visible:` | Keyboard focus only | `focus-visible:outline-2` |
+| `focus-within:` | Child is focused | `focus-within:ring-2` |
+| `active:` | Being clicked/pressed | `active:scale-95` |
+| `disabled:` | `disabled` attribute | `disabled:opacity-50 disabled:cursor-not-allowed` |
+| `group-hover:` | Parent `.group` hovered | `group-hover:text-blue-600` |
+| `group-focus:` | Parent `.group` focused | `group-focus:ring-2` |
+| `peer-checked:` | Sibling `.peer` checked | `peer-checked:bg-blue-600` |
+| `peer-invalid:` | Sibling `.peer` invalid | `peer-invalid:text-red-500` |
+| `data-[state=open]:` | Custom data attribute | `data-[state=open]:rotate-180` |
+| `aria-expanded:` | `aria-expanded="true"` | `aria-expanded:bg-gray-100` |
+| `aria-selected:` | `aria-selected="true"` | `aria-selected:font-bold` |
+| `open:` | `<details>` or `<dialog>` open | `open:bg-gray-50` |
+| `first:` | First child | `first:rounded-t-lg` |
+| `last:` | Last child | `last:rounded-b-lg` |
+| `odd:` | Odd children | `odd:bg-gray-50` |
+| `even:` | Even children | `even:bg-white` |
+| `placeholder:` | Placeholder text | `placeholder:text-gray-400` |
+| `motion-reduce:` | Prefers reduced motion | `motion-reduce:transition-none` |
+| `motion-safe:` | No motion preference | `motion-safe:animate-bounce` |
+| `print:` | Print media | `print:hidden` |
 
-## Arbitrary Values
+### Group and Peer Patterns
 
 ```html
-<div class="w-[137px] h-[calc(100vh-64px)]">
+<!-- Group: parent state affects children -->
+<a href="#" class="group flex items-center gap-3 p-3 rounded-lg hover:bg-gray-100">
+  <div class="w-10 h-10 bg-gray-200 group-hover:bg-blue-100 rounded-lg"></div>
+  <span class="text-gray-700 group-hover:text-blue-600">Hover the whole card</span>
+</a>
+
+<!-- Named groups (nested groups) -->
+<div class="group/card p-4">
+  <div class="group/button">
+    <span class="group-hover/card:text-blue-600 group-hover/button:underline">
+      Responds to specific parent
+    </span>
+  </div>
+</div>
+
+<!-- Peer: sibling state affects next sibling -->
+<input type="checkbox" class="peer sr-only" id="toggle">
+<label for="toggle" class="peer-checked:bg-blue-600 peer-checked:text-white px-4 py-2 rounded-lg cursor-pointer">
+  Toggle me
+</label>
+
+<!-- Form validation with peer -->
+<input type="email" class="peer" required>
+<p class="hidden peer-invalid:block text-sm text-red-500 mt-1">
+  Please enter a valid email.
+</p>
 ```
 
-## Additional Resources
+## Common Gotchas
+
+| Gotcha | Why | Fix |
+|--------|-----|-----|
+| Dynamic class names don't work: `` `bg-${color}-500` `` | Tailwind scans source for complete class strings at build time. String interpolation produces classes it never sees. | Use complete classes: `const colors = { red: 'bg-red-500', blue: 'bg-blue-500' }` and select by key. |
+| Styles not applying (specificity) | Another CSS rule or `@apply` has higher specificity. | Use `!important` modifier: `!text-red-500`. Or restructure to avoid conflicts. |
+| `@apply` breaks with component libraries | `@apply` resolves at build time and can't access runtime theme values or conflict with scoped styles. | Prefer inline utility classes. Reserve `@apply` for base styles or markdown content. |
+| Prose plugin styles leak | `@tailwindcss/typography` `prose` applies broad element selectors (h1, p, a, etc.). | Scope with `prose` only on content wrappers. Use `not-prose` class to exclude sections. |
+| Classes missing in production | JIT content detection didn't scan the file containing the class. | Ensure `content` paths in config cover all template files including component libraries. |
+| Dark mode flash (FOUC) | Class-based dark mode renders light first until JS adds `dark` class. | Add inline `<script>` in `<head>` that reads localStorage and sets `dark` class before paint. |
+| Container queries not scoped | Child `@sm:` responds to nearest `@container` ancestor, which may not be the intended one. | Use named containers: `@container/card` and `@sm/card:flex`. |
+| Arbitrary values vs config | `w-[137px]` works but creates one-off values. Repeated arbitrary values signal missing design tokens. | Add recurring values to theme config: `spacing: { '137': '137px' }`. |
+| `group` / `peer` naming collisions | Nested groups without names cause children to respond to wrong ancestor. | Use named groups: `group/card`, `group/button`. |
+| Responsive order matters | Adding `lg:flex` without base `block` or `hidden` can cause unexpected behavior on smaller screens. | Always define the mobile-first base, then override upward: `hidden lg:flex`. |
+| Transition on `display: none` | `hidden` to `block` can't be transitioned because `display` isn't animatable. | Use `opacity-0`/`opacity-100` with `invisible`/`visible`, or use `@starting-style` (v4). |
+| Purge removes dynamic classes | Tailwind purges classes not found as complete strings in scanned files. | Add classes to `safelist` in config, or use a safelist comment in the source file. |
+
+## Reference Files
+
+| File | Content | Lines |
+|------|---------|-------|
+| `references/component-patterns.md` | Cards, buttons, forms, navigation, modals, tables, alerts, badges, avatars, dropdowns, tooltips, skeleton loaders, accessibility | ~700 |
+| `references/v4-migration.md` | CSS-first config, @theme, @plugin, removed utilities, container queries, @starting-style, migration steps, breaking changes | ~500 |
+| `references/configuration.md` | Theme config (v3+v4), colors, spacing, typography, plugins, @layer, @apply, custom variants, dark mode, container queries | ~500 |
+
+## See Also
 
-For detailed patterns, load:
-- `./references/component-patterns.md` - Navbar, cards, forms, alerts, loading states
+- `react-ops` - React component patterns using Tailwind
+- `vue-ops` - Vue component patterns using Tailwind
+- `astro-ops` - Astro project patterns with Tailwind integration
+- Tailwind docs: https://tailwindcss.com/docs
+- Tailwind v4 blog: https://tailwindcss.com/blog/tailwindcss-v4

+ 965 - 206
skills/tailwind-ops/references/component-patterns.md

@@ -1,317 +1,1076 @@
 # Tailwind Component Patterns
 
-Ready-to-use component patterns with Tailwind CSS.
+Complete, accessible component patterns with Tailwind CSS. All examples include dark mode support and accessibility attributes.
 
-## Navigation
+## Cards
 
-### Navbar
+### Basic Card
 
 ```html
-<nav class="bg-white shadow">
-  <div class="container mx-auto px-4">
-    <div class="flex items-center justify-between h-16">
-      <!-- Logo -->
-      <a href="/" class="text-xl font-bold text-gray-900">Logo</a>
-
-      <!-- Desktop Menu -->
-      <div class="hidden md:flex items-center space-x-8">
-        <a href="#" class="text-gray-600 hover:text-gray-900">Home</a>
-        <a href="#" class="text-gray-600 hover:text-gray-900">Features</a>
-        <a href="#" class="text-gray-600 hover:text-gray-900">Pricing</a>
-        <a href="#" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700">
-          Get Started
-        </a>
-      </div>
-
-      <!-- Mobile Menu Button -->
-      <button class="md:hidden p-2">
-        <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
-                d="M4 6h16M4 12h16M4 18h16"/>
-        </svg>
-      </button>
-    </div>
-  </div>
-</nav>
+<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
+  <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Card Title</h3>
+  <p class="text-gray-600 dark:text-gray-400">Card content goes here with a reasonable amount of text.</p>
+</div>
 ```
 
-### Sidebar
+### Card with Image
 
 ```html
-<aside class="w-64 bg-gray-900 text-white min-h-screen">
-  <div class="p-4">
-    <h2 class="text-lg font-semibold mb-4">Dashboard</h2>
-    <nav class="space-y-2">
-      <a href="#" class="flex items-center px-4 py-2 bg-gray-800 rounded-lg">
-        <svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
-                d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
-        </svg>
-        Home
-      </a>
-      <a href="#" class="flex items-center px-4 py-2 text-gray-400 hover:bg-gray-800 hover:text-white rounded-lg transition-colors">
-        <svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
-                d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
-        </svg>
-        Users
-      </a>
-    </nav>
+<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
+  <img src="image.jpg" alt="Description of image" class="w-full h-48 object-cover">
+  <div class="p-6">
+    <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Card Title</h3>
+    <p class="text-gray-600 dark:text-gray-400 mb-4">Supporting text for this card.</p>
+    <a href="#" class="text-blue-600 dark:text-blue-400 hover:underline font-medium">Read more &rarr;</a>
   </div>
-</aside>
+</div>
 ```
 
-## Cards
-
-### Feature Card
+### Horizontal Card
 
 ```html
-<div class="bg-white rounded-xl shadow-lg p-6 hover:shadow-xl transition-shadow">
-  <div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mb-4">
-    <svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
-            d="M13 10V3L4 14h7v7l9-11h-7z"/>
-    </svg>
-  </div>
-  <h3 class="text-lg font-semibold text-gray-900 mb-2">Lightning Fast</h3>
-  <p class="text-gray-600">Optimized for speed with edge computing and CDN distribution.</p>
-</div>
-```
-
-### Profile Card
-
-```html
-<div class="bg-white rounded-xl shadow-lg overflow-hidden max-w-sm">
-  <div class="h-32 bg-gradient-to-r from-blue-500 to-purple-600"></div>
-  <div class="relative px-6 pb-6">
-    <img src="avatar.jpg" alt="Profile"
-         class="w-24 h-24 rounded-full border-4 border-white absolute -top-12">
-    <div class="pt-16">
-      <h3 class="text-xl font-bold text-gray-900">Jane Doe</h3>
-      <p class="text-gray-500">Senior Developer</p>
-      <div class="flex gap-4 mt-4">
-        <div class="text-center">
-          <div class="text-xl font-bold text-gray-900">142</div>
-          <div class="text-sm text-gray-500">Posts</div>
-        </div>
-        <div class="text-center">
-          <div class="text-xl font-bold text-gray-900">4.2k</div>
-          <div class="text-sm text-gray-500">Followers</div>
-        </div>
-      </div>
-    </div>
+<div class="flex flex-col sm:flex-row bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
+  <img src="image.jpg" alt="Description" class="w-full sm:w-48 h-48 sm:h-auto object-cover">
+  <div class="p-6 flex flex-col justify-center">
+    <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Horizontal Card</h3>
+    <p class="text-gray-600 dark:text-gray-400">Image sits beside the content on larger screens, stacks on mobile.</p>
   </div>
 </div>
 ```
 
+### Interactive Card (Clickable)
+
+```html
+<a href="#" class="block bg-white dark:bg-gray-800 rounded-lg shadow-md p-6
+                   hover:shadow-lg hover:ring-2 hover:ring-blue-500/20
+                   focus-visible:outline-2 focus-visible:outline-blue-600
+                   transition-all duration-200">
+  <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Clickable Card</h3>
+  <p class="text-gray-600 dark:text-gray-400">The entire card is a link with hover and focus states.</p>
+</a>
+```
+
 ### Pricing Card
 
 ```html
-<div class="bg-white rounded-2xl shadow-xl p-8 border-2 border-blue-500 relative">
+<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 border-2 border-blue-500 relative">
   <div class="absolute -top-4 left-1/2 -translate-x-1/2 bg-blue-500 text-white px-4 py-1 rounded-full text-sm font-medium">
-    Popular
+    Most Popular
   </div>
-  <h3 class="text-xl font-bold text-gray-900">Pro</h3>
+  <h3 class="text-xl font-bold text-gray-900 dark:text-white">Pro Plan</h3>
   <div class="mt-4">
-    <span class="text-4xl font-bold">$29</span>
-    <span class="text-gray-500">/month</span>
+    <span class="text-4xl font-bold text-gray-900 dark:text-white">$29</span>
+    <span class="text-gray-500 dark:text-gray-400">/month</span>
   </div>
-  <ul class="mt-6 space-y-3">
-    <li class="flex items-center text-gray-600">
-      <svg class="w-5 h-5 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
-        <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
+  <ul class="mt-6 space-y-3" role="list">
+    <li class="flex items-center text-gray-600 dark:text-gray-400">
+      <svg class="w-5 h-5 text-green-500 mr-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
+        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
       </svg>
       Unlimited projects
     </li>
-    <li class="flex items-center text-gray-600">
-      <svg class="w-5 h-5 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
-        <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
+    <li class="flex items-center text-gray-600 dark:text-gray-400">
+      <svg class="w-5 h-5 text-green-500 mr-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
+        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
       </svg>
       Advanced analytics
     </li>
-    <li class="flex items-center text-gray-600">
-      <svg class="w-5 h-5 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
-        <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
+    <li class="flex items-center text-gray-600 dark:text-gray-400">
+      <svg class="w-5 h-5 text-green-500 mr-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
+        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
       </svg>
       Priority support
     </li>
   </ul>
-  <button class="w-full mt-8 bg-blue-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors">
+  <button class="w-full mt-8 bg-blue-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 transition-colors">
     Get Started
   </button>
 </div>
 ```
 
+### Feature Card
+
+```html
+<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 hover:shadow-xl transition-shadow">
+  <div class="w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center mb-4">
+    <svg class="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
+      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
+    </svg>
+  </div>
+  <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Feature Name</h3>
+  <p class="text-gray-600 dark:text-gray-400">Description of this feature and its value to users.</p>
+</div>
+```
+
+## Buttons
+
+### Primary Button
+
+```html
+<button class="bg-blue-600 text-white px-4 py-2 rounded-lg font-medium
+               hover:bg-blue-700 active:bg-blue-800
+               focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600
+               transition-colors">
+  Primary Action
+</button>
+```
+
+### Secondary Button
+
+```html
+<button class="bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-lg font-medium
+               hover:bg-gray-300 dark:hover:bg-gray-600 active:bg-gray-400
+               focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500
+               transition-colors">
+  Secondary Action
+</button>
+```
+
+### Outline Button
+
+```html
+<button class="border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 px-4 py-2 rounded-lg font-medium
+               hover:bg-gray-50 dark:hover:bg-gray-800 active:bg-gray-100
+               focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500
+               transition-colors">
+  Outline Action
+</button>
+```
+
+### Ghost Button
+
+```html
+<button class="text-blue-600 dark:text-blue-400 px-4 py-2 rounded-lg font-medium
+               hover:bg-blue-50 dark:hover:bg-blue-900/20 active:bg-blue-100
+               focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600
+               transition-colors">
+  Ghost Action
+</button>
+```
+
+### Icon Button
+
+```html
+<button class="p-2 rounded-lg text-gray-500 dark:text-gray-400
+               hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-200
+               focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500
+               transition-colors"
+        aria-label="Close">
+  <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
+    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
+  </svg>
+</button>
+```
+
+### Loading Button
+
+```html
+<button class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg font-medium
+               disabled:opacity-60 disabled:cursor-not-allowed transition-colors" disabled>
+  <svg class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" aria-hidden="true">
+    <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
+    <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
+  </svg>
+  Processing...
+</button>
+```
+
+### Button Group
+
+```html
+<div class="inline-flex rounded-lg shadow-sm" role="group">
+  <button class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800
+                 border border-gray-300 dark:border-gray-600 rounded-l-lg
+                 hover:bg-gray-50 dark:hover:bg-gray-700 focus:z-10 focus-visible:outline-2 focus-visible:outline-blue-600">
+    Left
+  </button>
+  <button class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800
+                 border-t border-b border-gray-300 dark:border-gray-600
+                 hover:bg-gray-50 dark:hover:bg-gray-700 focus:z-10 focus-visible:outline-2 focus-visible:outline-blue-600">
+    Center
+  </button>
+  <button class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800
+                 border border-gray-300 dark:border-gray-600 rounded-r-lg
+                 hover:bg-gray-50 dark:hover:bg-gray-700 focus:z-10 focus-visible:outline-2 focus-visible:outline-blue-600">
+    Right
+  </button>
+</div>
+```
+
+### Button Sizes
+
+```html
+<!-- Small -->
+<button class="bg-blue-600 text-white px-3 py-1.5 text-sm rounded-md font-medium hover:bg-blue-700 transition-colors">Small</button>
+<!-- Medium (default) -->
+<button class="bg-blue-600 text-white px-4 py-2 text-base rounded-lg font-medium hover:bg-blue-700 transition-colors">Medium</button>
+<!-- Large -->
+<button class="bg-blue-600 text-white px-6 py-3 text-lg rounded-lg font-medium hover:bg-blue-700 transition-colors">Large</button>
+```
+
 ## Forms
 
-### Login Form
+### Text Input
 
 ```html
-<form class="bg-white rounded-xl shadow-lg p-8 max-w-md mx-auto">
-  <h2 class="text-2xl font-bold text-gray-900 mb-6">Welcome back</h2>
+<div>
+  <label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Full Name</label>
+  <input type="text" id="name"
+    class="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600
+           text-gray-900 dark:text-gray-100 rounded-lg
+           placeholder:text-gray-400 dark:placeholder:text-gray-500
+           focus:ring-2 focus:ring-blue-500 focus:border-transparent
+           disabled:bg-gray-100 dark:disabled:bg-gray-900 disabled:cursor-not-allowed"
+    placeholder="John Doe">
+</div>
+```
 
-  <div class="space-y-4">
-    <div>
-      <label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
-      <input type="email" id="email"
-             class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
-             placeholder="you@example.com">
-    </div>
+### Textarea
 
-    <div>
-      <label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
-      <input type="password" id="password"
-             class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
-             placeholder="••••••••">
-    </div>
+```html
+<div>
+  <label for="message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Message</label>
+  <textarea id="message" rows="4"
+    class="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600
+           text-gray-900 dark:text-gray-100 rounded-lg resize-y
+           placeholder:text-gray-400 dark:placeholder:text-gray-500
+           focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+    placeholder="Write your message..."></textarea>
+  <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Max 500 characters.</p>
+</div>
+```
 
-    <div class="flex items-center justify-between">
-      <label class="flex items-center">
-        <input type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
-        <span class="ml-2 text-sm text-gray-600">Remember me</span>
-      </label>
-      <a href="#" class="text-sm text-blue-600 hover:underline">Forgot password?</a>
-    </div>
+### Select
+
+```html
+<div>
+  <label for="country" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Country</label>
+  <select id="country"
+    class="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600
+           text-gray-900 dark:text-gray-100 rounded-lg
+           focus:ring-2 focus:ring-blue-500 focus:border-transparent">
+    <option value="">Select a country</option>
+    <option value="us">United States</option>
+    <option value="uk">United Kingdom</option>
+    <option value="ca">Canada</option>
+  </select>
+</div>
+```
+
+### Checkbox
+
+```html
+<div class="flex items-start">
+  <input type="checkbox" id="terms"
+    class="mt-1 h-4 w-4 rounded border-gray-300 dark:border-gray-600
+           text-blue-600 focus:ring-blue-500 dark:bg-gray-800">
+  <label for="terms" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
+    I agree to the <a href="#" class="text-blue-600 dark:text-blue-400 hover:underline">terms and conditions</a>
+  </label>
+</div>
+```
 
-    <button type="submit"
-            class="w-full bg-blue-600 text-white py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors">
-      Sign in
-    </button>
+### Radio Group
+
+```html
+<fieldset>
+  <legend class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notification preference</legend>
+  <div class="space-y-2">
+    <label class="flex items-center">
+      <input type="radio" name="notification" value="email"
+        class="h-4 w-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-800">
+      <span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Email</span>
+    </label>
+    <label class="flex items-center">
+      <input type="radio" name="notification" value="sms"
+        class="h-4 w-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-800">
+      <span class="ml-2 text-sm text-gray-700 dark:text-gray-300">SMS</span>
+    </label>
+    <label class="flex items-center">
+      <input type="radio" name="notification" value="push"
+        class="h-4 w-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-800">
+      <span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Push notification</span>
+    </label>
   </div>
-</form>
+</fieldset>
 ```
 
-### Search Input with Button
+### Toggle Switch
 
 ```html
-<div class="flex max-w-lg">
-  <input type="text"
-         class="flex-1 px-4 py-2 border border-gray-300 rounded-l-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
-         placeholder="Search...">
-  <button class="px-6 py-2 bg-blue-600 text-white rounded-r-lg hover:bg-blue-700 transition-colors">
-    Search
+<label class="relative inline-flex items-center cursor-pointer">
+  <input type="checkbox" class="sr-only peer">
+  <div class="w-11 h-6 bg-gray-200 dark:bg-gray-700 rounded-full
+              peer-checked:bg-blue-600 peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500
+              after:content-[''] after:absolute after:top-0.5 after:left-[2px]
+              after:bg-white after:rounded-full after:h-5 after:w-5
+              after:transition-all peer-checked:after:translate-x-full"></div>
+  <span class="ml-3 text-sm text-gray-700 dark:text-gray-300">Enable notifications</span>
+</label>
+```
+
+### File Upload
+
+```html
+<div>
+  <label for="file-upload" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Upload file</label>
+  <div class="flex items-center justify-center w-full">
+    <label for="file-upload"
+      class="flex flex-col items-center justify-center w-full h-32
+             border-2 border-dashed border-gray-300 dark:border-gray-600
+             rounded-lg cursor-pointer
+             hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/10
+             transition-colors">
+      <svg class="w-8 h-8 text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
+        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
+              d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
+      </svg>
+      <p class="text-sm text-gray-500 dark:text-gray-400"><span class="font-medium text-blue-600 dark:text-blue-400">Click to upload</span> or drag and drop</p>
+      <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">PNG, JPG, PDF up to 10MB</p>
+      <input type="file" id="file-upload" class="hidden">
+    </label>
+  </div>
+</div>
+```
+
+### Search Input
+
+```html
+<div class="relative">
+  <div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
+    <svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
+      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
+    </svg>
+  </div>
+  <input type="search"
+    class="w-full pl-10 pr-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600
+           text-gray-900 dark:text-gray-100 rounded-lg
+           placeholder:text-gray-400 dark:placeholder:text-gray-500
+           focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+    placeholder="Search...">
+</div>
+```
+
+### Input with Icon (Addon)
+
+```html
+<div>
+  <label for="website" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Website</label>
+  <div class="flex">
+    <span class="inline-flex items-center px-3 rounded-l-lg border border-r-0 border-gray-300 dark:border-gray-600
+                 bg-gray-50 dark:bg-gray-700 text-gray-500 dark:text-gray-400 text-sm">
+      https://
+    </span>
+    <input type="text" id="website"
+      class="flex-1 px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600
+             text-gray-900 dark:text-gray-100 rounded-r-lg
+             focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+      placeholder="example.com">
+  </div>
+</div>
+```
+
+### Input Group (Button Addon)
+
+```html
+<div class="flex">
+  <input type="email"
+    class="flex-1 px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600
+           text-gray-900 dark:text-gray-100 rounded-l-lg
+           focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+    placeholder="you@example.com">
+  <button class="px-6 py-2 bg-blue-600 text-white rounded-r-lg font-medium
+                 hover:bg-blue-700 focus-visible:outline-2 focus-visible:outline-blue-600
+                 transition-colors">
+    Subscribe
   </button>
 </div>
 ```
 
-## Lists
+### Validation States
+
+```html
+<!-- Error state -->
+<div>
+  <label for="email-err" class="block text-sm font-medium text-red-700 dark:text-red-400 mb-1">Email</label>
+  <input type="email" id="email-err"
+    class="w-full px-3 py-2 bg-white dark:bg-gray-800
+           border border-red-500 text-gray-900 dark:text-gray-100 rounded-lg
+           focus:ring-2 focus:ring-red-500 focus:border-transparent"
+    value="invalid-email" aria-describedby="email-error" aria-invalid="true">
+  <p id="email-error" class="mt-1 text-sm text-red-600 dark:text-red-400" role="alert">
+    Please enter a valid email address.
+  </p>
+</div>
+
+<!-- Success state -->
+<div>
+  <label for="email-ok" class="block text-sm font-medium text-green-700 dark:text-green-400 mb-1">Email</label>
+  <input type="email" id="email-ok"
+    class="w-full px-3 py-2 bg-white dark:bg-gray-800
+           border border-green-500 text-gray-900 dark:text-gray-100 rounded-lg
+           focus:ring-2 focus:ring-green-500 focus:border-transparent"
+    value="user@example.com" aria-describedby="email-success">
+  <p id="email-success" class="mt-1 text-sm text-green-600 dark:text-green-400">
+    Email address is valid.
+  </p>
+</div>
+```
+
+## Navigation
+
+### Horizontal Navbar
+
+```html
+<nav class="bg-white dark:bg-gray-900 shadow" aria-label="Main navigation">
+  <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
+    <div class="flex items-center justify-between h-16">
+      <a href="/" class="text-xl font-bold text-gray-900 dark:text-white">Brand</a>
+      <div class="hidden md:flex items-center gap-6">
+        <a href="#" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors">Home</a>
+        <a href="#" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors">Features</a>
+        <a href="#" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors">Pricing</a>
+        <a href="#" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">Get Started</a>
+      </div>
+      <!-- Mobile menu button -->
+      <button class="md:hidden p-2 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
+              aria-label="Toggle menu" aria-expanded="false">
+        <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
+          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
+        </svg>
+      </button>
+    </div>
+  </div>
+</nav>
+```
+
+### Mobile Hamburger Menu (Expanded State)
+
+```html
+<!-- Mobile menu panel (toggle with JS) -->
+<div class="md:hidden bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700">
+  <div class="px-4 py-3 space-y-1">
+    <a href="#" class="block px-3 py-2 rounded-lg text-gray-900 dark:text-white bg-gray-100 dark:bg-gray-800 font-medium">Home</a>
+    <a href="#" class="block px-3 py-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800">Features</a>
+    <a href="#" class="block px-3 py-2 rounded-lg text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800">Pricing</a>
+  </div>
+  <div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700">
+    <a href="#" class="block w-full text-center bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700">Get Started</a>
+  </div>
+</div>
+```
+
+### Sidebar Navigation
+
+```html
+<aside class="w-64 bg-gray-900 text-white min-h-screen flex flex-col" aria-label="Sidebar navigation">
+  <div class="p-4 border-b border-gray-800">
+    <h2 class="text-lg font-semibold">Dashboard</h2>
+  </div>
+  <nav class="flex-1 p-4 space-y-1">
+    <a href="#" class="flex items-center gap-3 px-3 py-2 bg-gray-800 rounded-lg text-white font-medium"
+       aria-current="page">
+      <svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
+        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
+      </svg>
+      Home
+    </a>
+    <a href="#" class="flex items-center gap-3 px-3 py-2 text-gray-400 hover:bg-gray-800 hover:text-white rounded-lg transition-colors">
+      <svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
+        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
+      </svg>
+      Users
+    </a>
+    <a href="#" class="flex items-center gap-3 px-3 py-2 text-gray-400 hover:bg-gray-800 hover:text-white rounded-lg transition-colors">
+      <svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
+        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
+        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
+      </svg>
+      Settings
+    </a>
+  </nav>
+</aside>
+```
+
+### Breadcrumbs
+
+```html
+<nav aria-label="Breadcrumb">
+  <ol class="flex items-center gap-2 text-sm">
+    <li><a href="#" class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">Home</a></li>
+    <li class="text-gray-400 dark:text-gray-500" aria-hidden="true">/</li>
+    <li><a href="#" class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">Products</a></li>
+    <li class="text-gray-400 dark:text-gray-500" aria-hidden="true">/</li>
+    <li class="text-gray-900 dark:text-white font-medium" aria-current="page">Widget Pro</li>
+  </ol>
+</nav>
+```
+
+### Tabs
+
+```html
+<div>
+  <div class="border-b border-gray-200 dark:border-gray-700" role="tablist">
+    <nav class="flex gap-0 -mb-px">
+      <button role="tab" aria-selected="true"
+        class="px-4 py-3 text-sm font-medium text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400">
+        General
+      </button>
+      <button role="tab" aria-selected="false"
+        class="px-4 py-3 text-sm font-medium text-gray-500 dark:text-gray-400 border-b-2 border-transparent
+               hover:text-gray-700 dark:hover:text-gray-200 hover:border-gray-300">
+        Security
+      </button>
+      <button role="tab" aria-selected="false"
+        class="px-4 py-3 text-sm font-medium text-gray-500 dark:text-gray-400 border-b-2 border-transparent
+               hover:text-gray-700 dark:hover:text-gray-200 hover:border-gray-300">
+        Billing
+      </button>
+    </nav>
+  </div>
+  <div role="tabpanel" class="p-4">
+    Tab content goes here.
+  </div>
+</div>
+```
+
+### Pagination
+
+```html
+<nav aria-label="Pagination">
+  <ul class="inline-flex items-center gap-1">
+    <li>
+      <a href="#" class="px-3 py-2 rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800" aria-label="Previous page">
+        &laquo;
+      </a>
+    </li>
+    <li><a href="#" class="px-3 py-2 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800">1</a></li>
+    <li><a href="#" class="px-3 py-2 rounded-lg bg-blue-600 text-white font-medium" aria-current="page">2</a></li>
+    <li><a href="#" class="px-3 py-2 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800">3</a></li>
+    <li><span class="px-3 py-2 text-gray-400">...</span></li>
+    <li><a href="#" class="px-3 py-2 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800">12</a></li>
+    <li>
+      <a href="#" class="px-3 py-2 rounded-lg text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800" aria-label="Next page">
+        &raquo;
+      </a>
+    </li>
+  </ul>
+</nav>
+```
+
+## Modals
+
+### Centered Modal with Overlay
+
+```html
+<div class="fixed inset-0 z-50 flex items-center justify-center" role="dialog" aria-modal="true" aria-labelledby="modal-title">
+  <!-- Backdrop -->
+  <div class="fixed inset-0 bg-black/50 transition-opacity" aria-hidden="true"></div>
+  <!-- Panel -->
+  <div class="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-lg mx-4 p-6">
+    <div class="flex items-center justify-between mb-4">
+      <h2 id="modal-title" class="text-lg font-semibold text-gray-900 dark:text-white">Edit Profile</h2>
+      <button class="p-1 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
+              aria-label="Close modal">
+        <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
+          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
+        </svg>
+      </button>
+    </div>
+    <div class="mb-6">
+      <p class="text-gray-600 dark:text-gray-400">Modal body content goes here.</p>
+    </div>
+    <div class="flex justify-end gap-3">
+      <button class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">Cancel</button>
+      <button class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">Save Changes</button>
+    </div>
+  </div>
+</div>
+```
 
-### Task List
+### Slide-Over Panel
 
 ```html
-<ul class="bg-white rounded-xl shadow divide-y">
-  <li class="flex items-center px-6 py-4 hover:bg-gray-50">
-    <input type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
-    <span class="ml-4 flex-1">Complete project documentation</span>
-    <span class="text-sm text-gray-500">Due tomorrow</span>
-  </li>
-  <li class="flex items-center px-6 py-4 hover:bg-gray-50">
-    <input type="checkbox" checked class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
-    <span class="ml-4 flex-1 line-through text-gray-400">Review pull requests</span>
-    <span class="text-sm text-gray-500">Completed</span>
-  </li>
-</ul>
+<div class="fixed inset-0 z-50 flex justify-end" role="dialog" aria-modal="true" aria-labelledby="slideover-title">
+  <div class="fixed inset-0 bg-black/50" aria-hidden="true"></div>
+  <div class="relative w-full max-w-md bg-white dark:bg-gray-800 shadow-xl flex flex-col h-full">
+    <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
+      <h2 id="slideover-title" class="text-lg font-semibold text-gray-900 dark:text-white">Panel Title</h2>
+      <button class="p-1 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" aria-label="Close panel">
+        <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
+          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
+        </svg>
+      </button>
+    </div>
+    <div class="flex-1 overflow-y-auto px-6 py-4">
+      <p class="text-gray-600 dark:text-gray-400">Slide-over content with vertical scroll.</p>
+    </div>
+    <div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
+      <button class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">Cancel</button>
+      <button class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">Apply</button>
+    </div>
+  </div>
+</div>
 ```
 
-### User List
+### Confirmation Dialog
 
 ```html
-<div class="bg-white rounded-xl shadow divide-y">
-  <div class="flex items-center px-6 py-4">
-    <img src="avatar1.jpg" class="w-10 h-10 rounded-full" alt="User">
-    <div class="ml-4 flex-1">
-      <h4 class="font-medium text-gray-900">John Smith</h4>
-      <p class="text-sm text-gray-500">john@example.com</p>
+<div class="fixed inset-0 z-50 flex items-center justify-center" role="alertdialog" aria-modal="true" aria-labelledby="confirm-title" aria-describedby="confirm-desc">
+  <div class="fixed inset-0 bg-black/50" aria-hidden="true"></div>
+  <div class="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-sm mx-4 p-6 text-center">
+    <div class="w-12 h-12 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
+      <svg class="w-6 h-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
+        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/>
+      </svg>
+    </div>
+    <h2 id="confirm-title" class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Delete Item?</h2>
+    <p id="confirm-desc" class="text-gray-600 dark:text-gray-400 mb-6">This action cannot be undone. The item will be permanently removed.</p>
+    <div class="flex gap-3 justify-center">
+      <button class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">Cancel</button>
+      <button class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">Delete</button>
     </div>
-    <span class="px-3 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full">Active</span>
   </div>
 </div>
 ```
 
-## Alerts/Badges
+## Tables
 
-### Alert Types
+### Basic Table
 
 ```html
-<!-- Success -->
-<div class="bg-green-50 border-l-4 border-green-500 p-4 rounded-r-lg">
-  <div class="flex items-center">
-    <svg class="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
-      <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
+<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
+  <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
+    <thead class="bg-gray-50 dark:bg-gray-800">
+      <tr>
+        <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Name</th>
+        <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Email</th>
+        <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Role</th>
+        <th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
+      </tr>
+    </thead>
+    <tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
+      <tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
+        <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">Jane Doe</td>
+        <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">jane@example.com</td>
+        <td class="px-6 py-4 whitespace-nowrap">
+          <span class="px-2 py-1 text-xs font-medium bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300 rounded-full">Admin</span>
+        </td>
+        <td class="px-6 py-4 whitespace-nowrap text-right text-sm">
+          <button class="text-blue-600 dark:text-blue-400 hover:underline">Edit</button>
+        </td>
+      </tr>
+    </tbody>
+  </table>
+</div>
+```
+
+### Striped Table
+
+```html
+<table class="min-w-full">
+  <thead>
+    <tr class="border-b border-gray-200 dark:border-gray-700">
+      <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Product</th>
+      <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Price</th>
+      <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Stock</th>
+    </tr>
+  </thead>
+  <tbody>
+    <tr class="odd:bg-white even:bg-gray-50 dark:odd:bg-gray-900 dark:even:bg-gray-800">
+      <td class="px-6 py-4 text-sm text-gray-900 dark:text-white">Widget A</td>
+      <td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">$9.99</td>
+      <td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">142</td>
+    </tr>
+    <tr class="odd:bg-white even:bg-gray-50 dark:odd:bg-gray-900 dark:even:bg-gray-800">
+      <td class="px-6 py-4 text-sm text-gray-900 dark:text-white">Widget B</td>
+      <td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">$19.99</td>
+      <td class="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">85</td>
+    </tr>
+  </tbody>
+</table>
+```
+
+### Sortable Table Header
+
+```html
+<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
+  <button class="group inline-flex items-center gap-1 hover:text-gray-700 dark:hover:text-gray-200">
+    Name
+    <svg class="w-4 h-4 text-gray-400 group-hover:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
+      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 11l5-5m0 0l5 5m-5-5v12"/>
     </svg>
-    <span class="ml-3 text-green-800">Changes saved successfully!</span>
+  </button>
+</th>
+```
+
+### Responsive Table (Stacked on Mobile)
+
+```html
+<!-- Horizontal scroll approach -->
+<div class="overflow-x-auto -mx-4 sm:mx-0">
+  <div class="inline-block min-w-full align-middle">
+    <table class="min-w-full"><!-- table content --></table>
   </div>
 </div>
 
-<!-- Error -->
-<div class="bg-red-50 border-l-4 border-red-500 p-4 rounded-r-lg">
-  <div class="flex items-center">
-    <svg class="w-5 h-5 text-red-500" fill="currentColor" viewBox="0 0 20 20">
-      <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
-    </svg>
-    <span class="ml-3 text-red-800">Error processing your request.</span>
+<!-- Stacked approach (card-like on mobile) -->
+<div class="sm:hidden space-y-4">
+  <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4 space-y-2">
+    <div class="flex justify-between">
+      <span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Name</span>
+      <span class="text-sm text-gray-900 dark:text-white font-medium">Jane Doe</span>
+    </div>
+    <div class="flex justify-between">
+      <span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Email</span>
+      <span class="text-sm text-gray-500 dark:text-gray-400">jane@example.com</span>
+    </div>
+    <div class="flex justify-between">
+      <span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Role</span>
+      <span class="px-2 py-1 text-xs bg-green-100 text-green-800 rounded-full">Admin</span>
+    </div>
   </div>
 </div>
+<!-- Desktop table (hidden on mobile) -->
+<div class="hidden sm:block">
+  <table class="min-w-full"><!-- full table --></table>
+</div>
+```
+
+## Alerts
+
+### Info Alert
 
-<!-- Warning -->
-<div class="bg-yellow-50 border-l-4 border-yellow-500 p-4 rounded-r-lg">
-  <span class="text-yellow-800">Please review before submitting.</span>
+```html
+<div class="flex items-start gap-3 p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800" role="status">
+  <svg class="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 shrink-0" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
+    <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
+  </svg>
+  <p class="text-sm text-blue-800 dark:text-blue-300">A new version is available. Please update to continue.</p>
 </div>
+```
 
-<!-- Info -->
-<div class="bg-blue-50 border-l-4 border-blue-500 p-4 rounded-r-lg">
-  <span class="text-blue-800">New features are now available.</span>
+### Success Alert
+
+```html
+<div class="flex items-start gap-3 p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800" role="status">
+  <svg class="w-5 h-5 text-green-600 dark:text-green-400 mt-0.5 shrink-0" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
+    <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
+  </svg>
+  <p class="text-sm text-green-800 dark:text-green-300">Changes saved successfully.</p>
 </div>
 ```
 
-### Badges
+### Warning Alert
 
 ```html
-<!-- Status badges -->
-<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full">Active</span>
-<span class="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded-full">Pending</span>
-<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full">Inactive</span>
-<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 rounded-full">Draft</span>
+<div class="flex items-start gap-3 p-4 rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800" role="alert">
+  <svg class="w-5 h-5 text-yellow-600 dark:text-yellow-400 mt-0.5 shrink-0" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
+    <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
+  </svg>
+  <p class="text-sm text-yellow-800 dark:text-yellow-300">Your trial expires in 3 days. Upgrade to keep access.</p>
+</div>
+```
+
+### Error Alert
+
+```html
+<div class="flex items-start gap-3 p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800" role="alert">
+  <svg class="w-5 h-5 text-red-600 dark:text-red-400 mt-0.5 shrink-0" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
+    <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
+  </svg>
+  <p class="text-sm text-red-800 dark:text-red-300">Failed to save changes. Please try again.</p>
+</div>
+```
+
+### Dismissible Alert
+
+```html
+<div class="flex items-start gap-3 p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800" role="status">
+  <svg class="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 shrink-0" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
+    <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
+  </svg>
+  <p class="flex-1 text-sm text-blue-800 dark:text-blue-300">Tip: You can drag items to reorder them.</p>
+  <button class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 p-0.5" aria-label="Dismiss">
+    <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
+      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
+    </svg>
+  </button>
+</div>
+```
+
+## Badges
+
+### Inline Badge
+
+```html
+<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300">
+  New
+</span>
+```
+
+### Pill Badge
 
-<!-- Notification badge -->
+```html
+<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300">
+  Default
+</span>
+<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
+  Active
+</span>
+<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-300">
+  Removed
+</span>
+```
+
+### Badge with Dot Indicator
+
+```html
+<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-300">
+  <span class="w-1.5 h-1.5 bg-green-500 rounded-full" aria-hidden="true"></span>
+  Online
+</span>
+<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-300">
+  <span class="w-1.5 h-1.5 bg-yellow-500 rounded-full" aria-hidden="true"></span>
+  Idle
+</span>
+<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
+  <span class="w-1.5 h-1.5 bg-gray-400 rounded-full" aria-hidden="true"></span>
+  Offline
+</span>
+```
+
+### Notification Count Badge
+
+```html
 <div class="relative inline-block">
-  <button class="p-2">
-    <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+  <button class="p-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200" aria-label="Notifications (3 unread)">
+    <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
       <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
     </svg>
   </button>
-  <span class="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs rounded-full flex items-center justify-center">3</span>
+  <span class="absolute -top-1 -right-1 flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 rounded-full">
+    3
+  </span>
 </div>
 ```
 
-## Loading States
+## Avatars
+
+### Image Avatar
+
+```html
+<img src="user.jpg" alt="Jane Doe" class="w-10 h-10 rounded-full object-cover ring-2 ring-white dark:ring-gray-800">
+```
 
-### Spinner
+### Initials Avatar
 
 ```html
-<div class="flex items-center justify-center">
-  <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
+<div class="w-10 h-10 rounded-full bg-blue-600 flex items-center justify-center">
+  <span class="text-sm font-medium text-white">JD</span>
 </div>
 ```
 
-### Skeleton
+### Avatar Group (Stacked)
 
 ```html
-<div class="animate-pulse space-y-4">
-  <div class="h-4 bg-gray-200 rounded w-3/4"></div>
-  <div class="h-4 bg-gray-200 rounded w-1/2"></div>
-  <div class="h-4 bg-gray-200 rounded w-5/6"></div>
+<div class="flex -space-x-3">
+  <img src="user1.jpg" alt="User 1" class="w-10 h-10 rounded-full border-2 border-white dark:border-gray-800 object-cover">
+  <img src="user2.jpg" alt="User 2" class="w-10 h-10 rounded-full border-2 border-white dark:border-gray-800 object-cover">
+  <img src="user3.jpg" alt="User 3" class="w-10 h-10 rounded-full border-2 border-white dark:border-gray-800 object-cover">
+  <div class="w-10 h-10 rounded-full border-2 border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
+    <span class="text-xs font-medium text-gray-600 dark:text-gray-400">+5</span>
+  </div>
 </div>
 ```
 
-### Button Loading
+## Dropdowns
+
+### Basic Dropdown
 
 ```html
-<button class="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg" disabled>
-  <svg class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
-    <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
-    <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
+<div class="relative inline-block text-left">
+  <button class="inline-flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600
+                 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300
+                 hover:bg-gray-50 dark:hover:bg-gray-700 focus-visible:outline-2 focus-visible:outline-blue-600"
+          aria-expanded="true" aria-haspopup="true">
+    Options
+    <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
+      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
+    </svg>
+  </button>
+  <div class="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-10"
+       role="menu">
+    <a href="#" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700" role="menuitem">Edit</a>
+    <a href="#" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700" role="menuitem">Duplicate</a>
+    <div class="border-t border-gray-200 dark:border-gray-700 my-1"></div>
+    <a href="#" class="block px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-700" role="menuitem">Delete</a>
+  </div>
+</div>
+```
+
+### Dropdown with Dividers and Icons
+
+```html
+<div class="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-10" role="menu">
+  <a href="#" class="flex items-center gap-3 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700" role="menuitem">
+    <svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
+      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
+    </svg>
+    Edit
+  </a>
+  <a href="#" class="flex items-center gap-3 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700" role="menuitem">
+    <svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
+      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
+    </svg>
+    Duplicate
+  </a>
+  <div class="border-t border-gray-200 dark:border-gray-700 my-1"></div>
+  <a href="#" class="flex items-center gap-3 px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20" role="menuitem">
+    <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
+      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
+    </svg>
+    Delete
+  </a>
+</div>
+```
+
+## Tooltips
+
+### CSS-Only Tooltip
+
+```html
+<div class="relative group inline-block">
+  <button class="px-3 py-1.5 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg">
+    Hover me
+  </button>
+  <div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-1.5
+              bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg
+              opacity-0 group-hover:opacity-100 transition-opacity duration-200
+              pointer-events-none whitespace-nowrap"
+       role="tooltip">
+    Tooltip text here
+    <div class="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"></div>
+  </div>
+</div>
+```
+
+## Skeleton Loaders
+
+### Text Skeleton
+
+```html
+<div class="animate-pulse space-y-3">
+  <div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
+  <div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
+  <div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
+  <div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div>
+</div>
+```
+
+### Image Skeleton
+
+```html
+<div class="animate-pulse">
+  <div class="w-full h-48 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
+    <svg class="w-10 h-10 text-gray-300 dark:text-gray-600" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
+      <path fill-rule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clip-rule="evenodd"/>
+    </svg>
+  </div>
+</div>
+```
+
+### Card Skeleton
+
+```html
+<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 animate-pulse">
+  <div class="w-full h-40 bg-gray-200 dark:bg-gray-700 rounded-lg mb-4"></div>
+  <div class="h-5 bg-gray-200 dark:bg-gray-700 rounded w-2/3 mb-3"></div>
+  <div class="space-y-2">
+    <div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
+    <div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-4/5"></div>
+  </div>
+  <div class="flex items-center gap-3 mt-4">
+    <div class="w-8 h-8 bg-gray-200 dark:bg-gray-700 rounded-full"></div>
+    <div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-24"></div>
+  </div>
+</div>
+```
+
+## Accessibility Patterns
+
+### Focus-Visible Rings
+
+```html
+<!-- Keyboard-only focus ring (no ring on mouse click) -->
+<button class="px-4 py-2 bg-blue-600 text-white rounded-lg
+               focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
+  Keyboard Focus Only
+</button>
+
+<!-- Custom focus ring for dark backgrounds -->
+<a href="#" class="text-white focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white rounded">
+  Link on dark background
+</a>
+```
+
+### Screen Reader Only Text
+
+```html
+<!-- Visually hidden but read by screen readers -->
+<button aria-label="Close">
+  <svg class="w-5 h-5" aria-hidden="true"><!-- icon --></svg>
+  <span class="sr-only">Close dialog</span>
+</button>
+
+<!-- Skip to main content link -->
+<a href="#main-content"
+   class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4
+          focus:z-50 focus:px-4 focus:py-2 focus:bg-blue-600 focus:text-white focus:rounded-lg">
+  Skip to main content
+</a>
+```
+
+### ARIA Attributes with Tailwind
+
+```html
+<!-- aria-expanded toggle indicator -->
+<button class="flex items-center gap-2" aria-expanded="false">
+  Menu
+  <svg class="w-4 h-4 transition-transform aria-expanded:rotate-180" aria-hidden="true"
+       fill="none" stroke="currentColor" viewBox="0 0 24 24">
+    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
   </svg>
-  Processing...
 </button>
+
+<!-- aria-selected for tab panels -->
+<button role="tab" aria-selected="true"
+  class="px-4 py-2 aria-selected:bg-blue-600 aria-selected:text-white rounded-lg">
+  Selected Tab
+</button>
+
+<!-- aria-current for navigation -->
+<a href="#" aria-current="page"
+   class="text-gray-600 aria-[current=page]:text-blue-600 aria-[current=page]:font-bold">
+  Current Page
+</a>
+```
+
+### Reduced Motion
+
+```html
+<!-- Disable animations for users who prefer reduced motion -->
+<div class="animate-bounce motion-reduce:animate-none">
+  Bouncing content (static for reduced-motion users)
+</div>
+
+<!-- Only animate for users who haven't set a preference -->
+<div class="motion-safe:animate-pulse">
+  Pulses only when safe
+</div>
+
+<!-- Disable transitions globally -->
+<div class="transition-transform hover:scale-105 motion-reduce:transition-none motion-reduce:hover:scale-100">
+  Scales on hover (but not for reduced-motion users)
+</div>
 ```

+ 913 - 0
skills/tailwind-ops/references/configuration.md

@@ -0,0 +1,913 @@
+# Tailwind CSS Configuration
+
+Complete configuration reference covering both Tailwind v3 (JS config) and v4 (CSS-first config).
+
+## Tailwind v3 Configuration (tailwind.config.js)
+
+### Minimal Config
+
+```js
+// tailwind.config.js (v3)
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+  content: [
+    './src/**/*.{html,js,jsx,ts,tsx,vue,astro}',
+    './public/index.html',
+  ],
+  theme: {
+    extend: {},
+  },
+  plugins: [],
+}
+```
+
+### theme.extend vs theme Override
+
+```js
+module.exports = {
+  theme: {
+    // OVERRIDE: replaces ALL default colors (only these 3 exist)
+    colors: {
+      primary: '#3b82f6',
+      secondary: '#6b7280',
+      white: '#ffffff',
+    },
+
+    // EXTEND: adds to defaults (all defaults + these custom values)
+    extend: {
+      colors: {
+        brand: '#3b82f6',        // Adds brand color, keeps slate/gray/red/etc.
+        primary: {
+          50: '#eff6ff',
+          100: '#dbeafe',
+          500: '#3b82f6',
+          600: '#2563eb',
+          700: '#1d4ed8',
+          900: '#1e3a5f',
+        },
+      },
+      spacing: {
+        '18': '4.5rem',
+        '88': '22rem',
+        '128': '32rem',
+      },
+      borderRadius: {
+        '4xl': '2rem',
+      },
+    },
+  },
+}
+```
+
+**Rule**: Almost always use `theme.extend`. Only use direct `theme` override when you want to eliminate defaults entirely.
+
+### Screens (Breakpoints)
+
+```js
+module.exports = {
+  theme: {
+    // Override ALL breakpoints
+    screens: {
+      'sm': '640px',
+      'md': '768px',
+      'lg': '1024px',
+      'xl': '1280px',
+      '2xl': '1536px',
+    },
+
+    // Or extend with additional breakpoints
+    extend: {
+      screens: {
+        '3xl': '1920px',
+        'tall': { 'raw': '(min-height: 800px)' },  // Height-based
+      },
+    },
+  },
+}
+```
+
+## Tailwind v4 Configuration (@theme in CSS)
+
+### Basic @theme Block
+
+```css
+@import "tailwindcss";
+
+@theme {
+  /* Colors: generate bg-*, text-*, border-*, ring-*, etc. */
+  --color-brand: #3b82f6;
+  --color-brand-50: #eff6ff;
+  --color-brand-100: #dbeafe;
+  --color-brand-500: #3b82f6;
+  --color-brand-600: #2563eb;
+  --color-brand-700: #1d4ed8;
+  --color-brand-900: #1e3a5f;
+
+  /* Semantic colors */
+  --color-surface: #ffffff;
+  --color-surface-dark: #1f2937;
+  --color-on-surface: #111827;
+  --color-on-surface-dark: #f9fafb;
+
+  /* Fonts */
+  --font-sans: "Inter", system-ui, sans-serif;
+  --font-mono: "JetBrains Mono", ui-monospace, monospace;
+
+  /* Spacing */
+  --spacing-18: 4.5rem;
+  --spacing-128: 32rem;
+
+  /* Breakpoints */
+  --breakpoint-3xl: 1920px;
+
+  /* Border radius */
+  --radius-4xl: 2rem;
+
+  /* Animations */
+  --animate-fade-in: fade-in 0.3s ease-out forwards;
+}
+```
+
+### Clearing Default Values
+
+```css
+@theme {
+  /* Clear all default colors, only keep what you define */
+  --color-*: initial;
+
+  --color-white: #ffffff;
+  --color-black: #000000;
+  --color-gray-50: #f9fafb;
+  --color-gray-100: #f3f4f6;
+  --color-gray-200: #e5e7eb;
+  --color-gray-300: #d1d5db;
+  --color-gray-400: #9ca3af;
+  --color-gray-500: #6b7280;
+  --color-gray-600: #4b5563;
+  --color-gray-700: #374151;
+  --color-gray-800: #1f2937;
+  --color-gray-900: #111827;
+  --color-blue-500: #3b82f6;
+  --color-blue-600: #2563eb;
+  --color-blue-700: #1d4ed8;
+}
+```
+
+## Custom Colors
+
+### Color Palette Definition (v3)
+
+```js
+// tailwind.config.js
+const colors = require('tailwindcss/colors')
+
+module.exports = {
+  theme: {
+    extend: {
+      colors: {
+        // Reference built-in palette
+        gray: colors.slate,
+        primary: colors.blue,
+        success: colors.green,
+        warning: colors.amber,
+        danger: colors.red,
+
+        // Custom palette with full scale
+        brand: {
+          50: '#faf5ff',
+          100: '#f3e8ff',
+          200: '#e9d5ff',
+          300: '#d8b4fe',
+          400: '#c084fc',
+          500: '#a855f7',
+          600: '#9333ea',
+          700: '#7e22ce',
+          800: '#6b21a8',
+          900: '#581c87',
+          950: '#3b0764',
+        },
+      },
+    },
+  },
+}
+```
+
+### Color Palette Definition (v4)
+
+```css
+@theme {
+  --color-brand-50: #faf5ff;
+  --color-brand-100: #f3e8ff;
+  --color-brand-200: #e9d5ff;
+  --color-brand-300: #d8b4fe;
+  --color-brand-400: #c084fc;
+  --color-brand-500: #a855f7;
+  --color-brand-600: #9333ea;
+  --color-brand-700: #7e22ce;
+  --color-brand-800: #6b21a8;
+  --color-brand-900: #581c87;
+  --color-brand-950: #3b0764;
+}
+```
+
+### Opacity Variants
+
+```html
+<!-- Opacity modifier works with any color (v3.1+ and v4) -->
+<div class="bg-brand-500/75">75% opacity</div>
+<div class="bg-brand-500/50">50% opacity</div>
+<div class="bg-brand-500/25">25% opacity</div>
+<div class="text-brand-700/90">90% text opacity</div>
+<div class="border-brand-300/50">50% border opacity</div>
+```
+
+### Semantic Colors (Design Tokens)
+
+```css
+/* v4: Define semantic tokens that reference palette values */
+@theme {
+  --color-primary: var(--color-brand-600);
+  --color-primary-hover: var(--color-brand-700);
+  --color-secondary: var(--color-gray-600);
+  --color-secondary-hover: var(--color-gray-700);
+  --color-accent: var(--color-amber-500);
+
+  --color-surface: var(--color-white);
+  --color-surface-elevated: var(--color-gray-50);
+  --color-on-surface: var(--color-gray-900);
+  --color-on-surface-muted: var(--color-gray-500);
+
+  --color-destructive: var(--color-red-600);
+  --color-destructive-hover: var(--color-red-700);
+}
+```
+
+```html
+<!-- Usage with semantic tokens -->
+<button class="bg-primary text-white hover:bg-primary-hover">Primary Action</button>
+<button class="bg-destructive text-white hover:bg-destructive-hover">Delete</button>
+<div class="bg-surface text-on-surface">Card on surface</div>
+```
+
+## Custom Spacing
+
+### Extending the Spacing Scale
+
+```js
+// v3: tailwind.config.js
+module.exports = {
+  theme: {
+    extend: {
+      spacing: {
+        '13': '3.25rem',   // 52px
+        '15': '3.75rem',   // 60px
+        '18': '4.5rem',    // 72px
+        '88': '22rem',     // 352px
+        '128': '32rem',    // 512px
+        'header': '64px',  // Named spacing
+        'sidebar': '280px',
+      },
+    },
+  },
+}
+```
+
+```css
+/* v4: @theme */
+@theme {
+  --spacing-13: 3.25rem;
+  --spacing-15: 3.75rem;
+  --spacing-18: 4.5rem;
+  --spacing-88: 22rem;
+  --spacing-128: 32rem;
+  --spacing-header: 64px;
+  --spacing-sidebar: 280px;
+}
+```
+
+### Arbitrary Spacing Values
+
+```html
+<!-- One-off values (use sparingly, prefer config for repeated values) -->
+<div class="p-[13px]">Arbitrary padding</div>
+<div class="mt-[clamp(1rem,3vw,2rem)]">Fluid margin</div>
+<div class="w-[calc(100%-250px)]">Calculated width</div>
+<div class="h-[calc(100vh-var(--spacing-header))]">Dynamic height</div>
+```
+
+## Typography
+
+### Font Families
+
+```js
+// v3: tailwind.config.js
+module.exports = {
+  theme: {
+    extend: {
+      fontFamily: {
+        sans: ['Inter', 'system-ui', 'sans-serif'],
+        display: ['Poppins', 'system-ui', 'sans-serif'],
+        body: ['Source Sans Pro', 'system-ui', 'sans-serif'],
+        mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
+      },
+    },
+  },
+}
+```
+
+```css
+/* v4: @theme */
+@theme {
+  --font-sans: "Inter", system-ui, sans-serif;
+  --font-display: "Poppins", system-ui, sans-serif;
+  --font-body: "Source Sans Pro", system-ui, sans-serif;
+  --font-mono: "JetBrains Mono", "Fira Code", monospace;
+}
+```
+
+```html
+<h1 class="font-display text-4xl font-bold">Display Heading</h1>
+<p class="font-body text-base">Body text with Source Sans Pro</p>
+<code class="font-mono text-sm">Code block</code>
+```
+
+### Font Sizes
+
+```js
+// v3: Custom font sizes with line-height
+module.exports = {
+  theme: {
+    extend: {
+      fontSize: {
+        'xs': ['0.75rem', { lineHeight: '1rem' }],
+        'tiny': ['0.625rem', { lineHeight: '0.875rem' }],
+        'hero': ['4rem', { lineHeight: '1.1', letterSpacing: '-0.02em', fontWeight: '800' }],
+      },
+    },
+  },
+}
+```
+
+```css
+/* v4: Font size with associated properties */
+@theme {
+  --text-tiny: 0.625rem;
+  --text-tiny--line-height: 0.875rem;
+
+  --text-hero: 4rem;
+  --text-hero--line-height: 1.1;
+  --text-hero--letter-spacing: -0.02em;
+  --text-hero--font-weight: 800;
+}
+```
+
+```html
+<h1 class="text-hero">Hero Heading</h1>
+<span class="text-tiny uppercase tracking-wider">Label</span>
+```
+
+### Line Heights
+
+```html
+<!-- Relative line heights -->
+<p class="leading-none">1.0 line-height</p>
+<p class="leading-tight">1.25 line-height</p>
+<p class="leading-snug">1.375 line-height</p>
+<p class="leading-normal">1.5 line-height (default)</p>
+<p class="leading-relaxed">1.625 line-height</p>
+<p class="leading-loose">2.0 line-height</p>
+
+<!-- Fixed line heights -->
+<p class="leading-4">1rem (16px)</p>
+<p class="leading-6">1.5rem (24px)</p>
+<p class="leading-8">2rem (32px)</p>
+```
+
+### @tailwindcss/typography Plugin (Prose Classes)
+
+```bash
+npm install @tailwindcss/typography
+```
+
+```js
+// v3
+module.exports = {
+  plugins: [require('@tailwindcss/typography')],
+}
+```
+
+```css
+/* v4 */
+@plugin "@tailwindcss/typography";
+```
+
+```html
+<!-- Apply prose to markdown/CMS content wrappers -->
+<article class="prose dark:prose-invert lg:prose-lg max-w-none">
+  <h1>Article Title</h1>
+  <p>Rendered markdown with beautiful typography defaults.</p>
+  <blockquote>Styled blockquotes.</blockquote>
+  <pre><code>Styled code blocks</code></pre>
+
+  <!-- Exclude sections from prose -->
+  <div class="not-prose">
+    <button class="bg-blue-600 text-white px-4 py-2 rounded-lg">
+      Not affected by prose
+    </button>
+  </div>
+</article>
+```
+
+**Prose modifiers**: `prose-sm`, `prose-base`, `prose-lg`, `prose-xl`, `prose-2xl`, `prose-invert` (dark mode), `prose-gray`, `prose-slate`, `prose-zinc`.
+
+## Plugins
+
+### Writing Plugins (v3)
+
+```js
+const plugin = require('tailwindcss/plugin')
+
+module.exports = {
+  plugins: [
+    // addUtilities: generate utility classes
+    plugin(function ({ addUtilities }) {
+      addUtilities({
+        '.text-balance': { 'text-wrap': 'balance' },
+        '.text-pretty': { 'text-wrap': 'pretty' },
+        '.content-auto': { 'content-visibility': 'auto' },
+      })
+    }),
+
+    // addComponents: generate component classes
+    plugin(function ({ addComponents, theme }) {
+      addComponents({
+        '.btn': {
+          padding: `${theme('spacing.2')} ${theme('spacing.4')}`,
+          borderRadius: theme('borderRadius.lg'),
+          fontWeight: theme('fontWeight.medium'),
+          fontSize: theme('fontSize.sm'),
+          lineHeight: theme('lineHeight.5'),
+        },
+      })
+    }),
+
+    // matchUtilities: generate dynamic utilities with values
+    plugin(function ({ matchUtilities, theme }) {
+      matchUtilities(
+        {
+          'grid-area': (value) => ({ gridArea: value }),
+        },
+        { values: { header: 'header', main: 'main', sidebar: 'sidebar', footer: 'footer' } }
+      )
+    }),
+  ],
+}
+```
+
+### Popular Plugins
+
+| Plugin | Purpose | Install |
+|--------|---------|---------|
+| `@tailwindcss/typography` | Prose classes for rich content | `npm i @tailwindcss/typography` |
+| `@tailwindcss/forms` | Better default form styles | `npm i @tailwindcss/forms` |
+| `@tailwindcss/container-queries` | Container queries (v3) | `npm i @tailwindcss/container-queries` |
+| `tailwindcss-animate` | Animation utilities (shadcn) | `npm i tailwindcss-animate` |
+| `@tailwindcss/aspect-ratio` | Aspect ratio (pre-native) | `npm i @tailwindcss/aspect-ratio` |
+
+```js
+// v3: Using plugins
+module.exports = {
+  plugins: [
+    require('@tailwindcss/typography'),
+    require('@tailwindcss/forms'),
+    require('@tailwindcss/container-queries'),
+    require('tailwindcss-animate'),
+  ],
+}
+```
+
+```css
+/* v4: Using plugins */
+@plugin "@tailwindcss/typography";
+@plugin "@tailwindcss/forms";
+/* container-queries not needed in v4 (native) */
+@plugin "tailwindcss-animate";
+```
+
+## Content Configuration
+
+### Template Paths (v3)
+
+```js
+// v3: Tell Tailwind where to find class usage
+module.exports = {
+  content: [
+    './src/**/*.{html,js,jsx,ts,tsx,vue,svelte,astro}',
+    './public/index.html',
+    './content/**/*.md',
+    // Include component libraries
+    './node_modules/@acme/ui/dist/**/*.js',
+  ],
+}
+```
+
+### Automatic Detection (v4)
+
+v4 automatically scans your project for template files. Override with `@source` if needed:
+
+```css
+/* v4: Explicit source paths (usually not needed) */
+@source "../content/**/*.md";
+@source "../node_modules/@acme/ui/dist/**/*.js";
+```
+
+### Safelisting
+
+```js
+// v3: Safelist classes that can't be detected
+module.exports = {
+  safelist: [
+    'bg-red-500',
+    'bg-green-500',
+    'bg-blue-500',
+    // Pattern-based
+    { pattern: /bg-(red|green|blue)-(100|500|900)/ },
+    // With variants
+    { pattern: /text-(red|green|blue)-500/, variants: ['hover', 'dark'] },
+  ],
+}
+```
+
+```css
+/* v4: Safelist via CSS comment */
+@source "safelist:bg-red-500,bg-green-500,bg-blue-500";
+```
+
+## @layer Directive
+
+### Layer Hierarchy
+
+```css
+@import "tailwindcss";
+
+/* BASE: Reset, HTML element defaults, @font-face */
+@layer base {
+  html {
+    scroll-behavior: smooth;
+    -webkit-font-smoothing: antialiased;
+  }
+
+  body {
+    font-family: var(--font-body);
+    color: var(--color-on-surface);
+    background-color: var(--color-surface);
+  }
+
+  h1, h2, h3, h4, h5, h6 {
+    font-family: var(--font-display);
+    font-weight: 700;
+  }
+
+  a {
+    color: var(--color-primary);
+    text-decoration-line: underline;
+    text-underline-offset: 2px;
+  }
+}
+
+/* COMPONENTS: Reusable multi-property classes */
+@layer components {
+  .card {
+    @apply rounded-lg bg-white p-6 shadow-md dark:bg-gray-800;
+  }
+
+  .btn {
+    @apply inline-flex items-center justify-center rounded-lg px-4 py-2
+           text-sm font-medium transition-colors
+           focus-visible:outline-2 focus-visible:outline-offset-2;
+  }
+
+  .btn-primary {
+    @apply btn bg-blue-600 text-white hover:bg-blue-700
+           focus-visible:outline-blue-600;
+  }
+
+  .input {
+    @apply w-full rounded-lg border border-gray-300 bg-white px-3 py-2
+           text-gray-900 placeholder:text-gray-400
+           focus:border-transparent focus:ring-2 focus:ring-blue-500
+           dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100;
+  }
+}
+
+/* UTILITIES: Single-property overrides */
+@layer utilities {
+  .text-balance {
+    text-wrap: balance;
+  }
+
+  .content-auto {
+    content-visibility: auto;
+  }
+
+  .scrollbar-hidden {
+    scrollbar-width: none;
+    &::-webkit-scrollbar { display: none; }
+  }
+}
+```
+
+### When to Use Each Layer
+
+| Layer | Use For | Example |
+|-------|---------|---------|
+| `base` | HTML element defaults, resets, `@font-face` | Body font, link colors, heading styles |
+| `components` | Multi-property reusable patterns | `.card`, `.btn`, `.input`, `.badge` |
+| `utilities` | Single-purpose utility classes | `.text-balance`, `.content-auto` |
+
+## @apply
+
+### When @apply is Appropriate
+
+```css
+/* GOOD: Component libraries where utility classes aren't available */
+@layer components {
+  .prose-custom h2 {
+    @apply text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4;
+  }
+}
+
+/* GOOD: Markdown content styling */
+@layer base {
+  .markdown-content h1 { @apply text-3xl font-bold mb-4; }
+  .markdown-content p { @apply text-gray-600 dark:text-gray-400 mb-4; }
+  .markdown-content a { @apply text-blue-600 hover:underline; }
+}
+
+/* GOOD: Repeated pattern across many elements */
+@layer components {
+  .btn {
+    @apply px-4 py-2 rounded-lg font-medium transition-colors
+           focus-visible:outline-2 focus-visible:outline-offset-2;
+  }
+}
+```
+
+### When NOT to Use @apply
+
+```html
+<!-- BAD: Using @apply when inline utilities work fine -->
+<!-- .my-div { @apply flex items-center gap-4 p-6; } -->
+
+<!-- GOOD: Inline utilities - easier to read, change, and delete -->
+<div class="flex items-center gap-4 p-6">
+```
+
+**Rule of thumb**: Use inline utilities by default. Only reach for `@apply` when:
+1. You need to style elements you don't control (CMS content, markdown)
+2. You're building a component library with `.btn`, `.card` classes
+3. A combination of 5+ utilities is repeated identically in 5+ places
+
+## Custom Variants
+
+### addVariant (v3)
+
+```js
+const plugin = require('tailwindcss/plugin')
+
+module.exports = {
+  plugins: [
+    plugin(function ({ addVariant }) {
+      // Simple variant
+      addVariant('hocus', ['&:hover', '&:focus'])
+      addVariant('supports-grid', '@supports (display: grid)')
+      addVariant('optional', '&:optional')
+
+      // Parent state
+      addVariant('group-sidebar', ':merge(.group-sidebar):hover &')
+    }),
+  ],
+}
+```
+
+### @custom-variant (v4)
+
+```css
+/* v4: Define custom variants in CSS */
+@custom-variant hocus (&:hover, &:focus);
+@custom-variant optional (&:optional);
+@custom-variant supports-grid (@supports (display: grid));
+
+/* Dark mode with custom selector */
+@custom-variant dark (&:where(.dark, .dark *));
+
+/* Dark mode with data attribute */
+@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
+```
+
+```html
+<!-- Usage -->
+<button class="bg-blue-600 hocus:bg-blue-700">Hover or focus</button>
+<input class="border-gray-300 optional:border-dashed" type="text">
+```
+
+### Data Attribute Variants
+
+```html
+<!-- Built-in data-* variant -->
+<div data-state="open" class="data-[state=open]:bg-blue-50 data-[state=closed]:bg-gray-50">
+  Responds to data-state attribute
+</div>
+
+<div data-size="lg" class="data-[size=sm]:text-sm data-[size=lg]:text-lg">
+  Responds to data-size
+</div>
+
+<!-- With boolean data attributes -->
+<div data-loading class="data-[loading]:animate-pulse">
+  Loading state
+</div>
+```
+
+## Prefix Configuration
+
+### v3: Avoiding Conflicts
+
+```js
+// v3: Add prefix to all Tailwind classes
+module.exports = {
+  prefix: 'tw-',
+}
+```
+
+```html
+<!-- All classes get tw- prefix -->
+<div class="tw-flex tw-items-center tw-gap-4 tw-bg-blue-500">
+```
+
+### v4: Prefix
+
+```css
+/* v4: Prefix via @import option */
+@import "tailwindcss" prefix(tw);
+```
+
+## Important Configuration
+
+### v3: Important Selector Strategy
+
+```js
+// v3: Make all utilities important
+module.exports = {
+  // Option 1: All utilities get !important
+  important: true,
+
+  // Option 2: Selector strategy (recommended)
+  important: '#app',
+}
+```
+
+### Per-Utility Important
+
+```html
+<!-- Add ! prefix for individual important override -->
+<div class="!text-red-500">This text is red regardless of other styles</div>
+<div class="bg-blue-500 !bg-red-500">Background is red</div>
+```
+
+## Dark Mode Configuration
+
+### Class Strategy (v3)
+
+```js
+// v3: tailwind.config.js
+module.exports = {
+  darkMode: 'class',  // Toggle via class="dark" on <html>
+}
+```
+
+```html
+<html class="dark">
+  <body class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
+```
+
+### Media Strategy (v3 and v4 Default)
+
+```js
+// v3: Uses system preference (prefers-color-scheme)
+module.exports = {
+  darkMode: 'media',  // Default in v4
+}
+```
+
+### Selector Strategy (v4)
+
+```css
+/* v4: Class-based dark mode */
+@custom-variant dark (&:where(.dark, .dark *));
+
+/* v4: Data attribute dark mode */
+@custom-variant dark (&:where([data-mode="dark"], [data-mode="dark"] *));
+```
+
+### Dark Mode Toggle Script
+
+```html
+<!-- Prevent flash of wrong theme: add to <head> before CSS -->
+<script>
+  const theme = localStorage.getItem('theme');
+  if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
+    document.documentElement.classList.add('dark');
+  }
+</script>
+```
+
+## Container Queries
+
+### Setup (v3)
+
+```bash
+npm install @tailwindcss/container-queries
+```
+
+```js
+// v3: tailwind.config.js
+module.exports = {
+  plugins: [require('@tailwindcss/container-queries')],
+}
+```
+
+### Setup (v4)
+
+Container queries are native in v4. No plugin needed.
+
+### Usage
+
+```html
+<!-- Unnamed container -->
+<div class="@container">
+  <div class="flex flex-col @sm:flex-row @md:grid @md:grid-cols-2 gap-4">
+    <div class="p-4">Responds to parent width</div>
+    <div class="p-4">Not viewport width</div>
+  </div>
+</div>
+
+<!-- Named container -->
+<div class="@container/sidebar">
+  <nav class="flex flex-col @md/sidebar:flex-row gap-2">
+    <a href="#">Link 1</a>
+    <a href="#">Link 2</a>
+  </nav>
+</div>
+```
+
+### Container Query Breakpoints
+
+| Variant | Min Width |
+|---------|-----------|
+| `@xs:` | 320px (20rem) |
+| `@sm:` | 384px (24rem) |
+| `@md:` | 448px (28rem) |
+| `@lg:` | 512px (32rem) |
+| `@xl:` | 576px (36rem) |
+| `@2xl:` | 672px (42rem) |
+| `@3xl:` | 768px (48rem) |
+| `@4xl:` | 896px (56rem) |
+| `@5xl:` | 1024px (64rem) |
+
+### Container Query Units
+
+```html
+<!-- Container query units: relative to container size -->
+<div class="@container">
+  <div class="w-[50cqw] h-[30cqh]">
+    50% of container width, 30% of container height
+  </div>
+</div>
+```
+
+| Unit | Description |
+|------|-------------|
+| `cqw` | 1% of container width |
+| `cqh` | 1% of container height |
+| `cqi` | 1% of container inline size |
+| `cqb` | 1% of container block size |
+| `cqmin` | Smaller of `cqi` / `cqb` |
+| `cqmax` | Larger of `cqi` / `cqb` |
+
+### Nested Containers
+
+```html
+<div class="@container/page">
+  <div class="grid @lg/page:grid-cols-[250px_1fr]">
+    <aside class="@container/sidebar">
+      <nav class="flex flex-col @md/sidebar:flex-row">Sidebar nav</nav>
+    </aside>
+    <main class="@container/content">
+      <div class="grid @md/content:grid-cols-2 @xl/content:grid-cols-3 gap-6">
+        <div>Card</div>
+      </div>
+    </main>
+  </div>
+</div>
+```

+ 507 - 0
skills/tailwind-ops/references/v4-migration.md

@@ -0,0 +1,507 @@
+# Tailwind CSS v4 Migration Guide
+
+Comprehensive guide for migrating from Tailwind CSS v3 to v4. Tailwind v4 is a ground-up rewrite with CSS-first configuration, new engine, and native support for modern CSS features.
+
+## What Changed: Overview
+
+### Architecture Shift
+
+Tailwind v4 replaces JavaScript-based configuration with CSS-first configuration. The `tailwind.config.js` file is no longer required (but supported via compatibility layer).
+
+| Concept | v3 | v4 |
+|---------|----|----|
+| Config | `tailwind.config.js` (JS) | `@theme {}` block in CSS |
+| Entry point | `@tailwind base/components/utilities` | `@import "tailwindcss"` |
+| Plugins | JS `plugin()` API | `@plugin "package"` in CSS |
+| PostCSS | `tailwindcss` package | `@tailwindcss/postcss` |
+| Vite | PostCSS plugin | `@tailwindcss/vite` (faster) |
+| Content detection | `content: [...]` in config | Automatic (scans project) |
+| Theme values | JS objects | CSS custom properties |
+| Directives | `@tailwind`, `@screen`, `@variants` | `@import`, `@theme`, `@variant` |
+
+## @theme Directive
+
+The `@theme` directive replaces `theme.extend` in `tailwind.config.js`. Values defined here become both CSS custom properties and Tailwind utilities.
+
+### Basic Usage
+
+```css
+/* v4: CSS-first configuration */
+@import "tailwindcss";
+
+@theme {
+  /* Colors: creates bg-brand, text-brand, border-brand, etc. */
+  --color-brand: #3b82f6;
+  --color-brand-light: #60a5fa;
+  --color-brand-dark: #1d4ed8;
+
+  /* Semantic colors */
+  --color-success: #22c55e;
+  --color-warning: #f59e0b;
+  --color-danger: #ef4444;
+
+  /* Typography */
+  --font-display: "Inter", "system-ui", sans-serif;
+  --font-body: "Source Sans Pro", "system-ui", sans-serif;
+  --font-mono: "JetBrains Mono", "Fira Code", monospace;
+
+  /* Custom spacing */
+  --spacing-18: 4.5rem;
+  --spacing-88: 22rem;
+  --spacing-128: 32rem;
+
+  /* Custom breakpoints */
+  --breakpoint-3xl: 1920px;
+
+  /* Animations */
+  --animate-fade-in: fade-in 0.3s ease-out;
+  --animate-slide-up: slide-up 0.4s ease-out;
+}
+
+@keyframes fade-in {
+  from { opacity: 0; transform: translateY(8px); }
+  to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes slide-up {
+  from { opacity: 0; transform: translateY(100%); }
+  to { opacity: 1; transform: translateY(0); }
+}
+```
+
+### Overriding vs Extending
+
+```css
+/* EXTENDING: add to existing scale (use --color-* namespace) */
+@theme {
+  --color-brand: #3b82f6;
+  /* All default colors (slate, gray, red, etc.) still available */
+}
+
+/* OVERRIDING: replace entire namespace */
+@theme {
+  --color-*: initial;  /* Clear all default colors */
+  --color-primary: #3b82f6;
+  --color-secondary: #6b7280;
+  --color-accent: #f59e0b;
+  /* Only these 3 colors available now */
+}
+```
+
+### Accessing Theme Values in CSS
+
+```css
+/* Theme values are CSS custom properties, usable anywhere */
+.custom-element {
+  color: var(--color-brand);
+  font-family: var(--font-display);
+  padding: var(--spacing-18);
+}
+```
+
+## @config: Compatibility Layer
+
+For projects with existing `tailwind.config.js` files, v4 provides a compatibility layer.
+
+```css
+@import "tailwindcss";
+
+/* Load existing JS config */
+@config "./tailwind.config.js";
+
+/* Can combine with @theme (theme overrides config) */
+@theme {
+  --color-brand: #3b82f6;
+}
+```
+
+**Migration path**: Start with `@config`, then gradually move values into `@theme`, then remove the JS config.
+
+## @plugin Directive
+
+Plugins are now imported directly in CSS.
+
+```css
+@import "tailwindcss";
+
+/* v4: Import plugins in CSS */
+@plugin "@tailwindcss/typography";
+@plugin "@tailwindcss/forms";
+@plugin "@tailwindcss/container-queries";
+
+/* Local plugin */
+@plugin "./plugins/my-custom-plugin.js";
+```
+
+### Plugin API Changes
+
+```js
+// v4 plugin API (similar to v3 but with changes)
+export default function ({ addUtilities, addComponents, matchUtilities, theme }) {
+  addUtilities({
+    '.content-auto': { 'content-visibility': 'auto' },
+    '.content-hidden': { 'content-visibility': 'hidden' },
+  })
+}
+```
+
+## New Default Scales
+
+### Spacing
+
+v4 uses a simplified spacing scale based on multiples of `0.25rem` (4px). The existing numeric scale (0-96) remains, but new scales are rationalized.
+
+### Colors
+
+v4 retains the same color palette names but adds:
+- OKLCH color support for more perceptually uniform colors
+- Automatic color opacity via `bg-blue-500/75` (unchanged syntax, improved output)
+
+### Typography
+
+```css
+@theme {
+  /* v4 uses --text-* for font-size + line-height combos */
+  --text-base: 1rem;           /* 16px */
+  --text-base--line-height: 1.5rem;  /* 24px */
+
+  /* Custom text scale */
+  --text-hero: 4rem;
+  --text-hero--line-height: 1.1;
+  --text-hero--letter-spacing: -0.02em;
+  --text-hero--font-weight: 800;
+}
+```
+
+## Removed Utilities and Replacements
+
+| v3 Utility | Status in v4 | Replacement |
+|------------|-------------|-------------|
+| `bg-opacity-*` | Removed | `bg-blue-500/75` (opacity modifier) |
+| `text-opacity-*` | Removed | `text-blue-500/75` |
+| `border-opacity-*` | Removed | `border-blue-500/75` |
+| `divide-opacity-*` | Removed | `divide-blue-500/75` |
+| `ring-opacity-*` | Removed | `ring-blue-500/75` |
+| `placeholder-opacity-*` | Removed | `placeholder:text-gray-400/75` |
+| `flex-shrink` | Renamed | `shrink` (already available in v3) |
+| `flex-grow` | Renamed | `grow` (already available in v3) |
+| `overflow-ellipsis` | Renamed | `text-ellipsis` |
+| `decoration-slice` | Renamed | `box-decoration-slice` |
+| `decoration-clone` | Renamed | `box-decoration-clone` |
+
+### Opacity Modifier Migration
+
+```html
+<!-- v3: Separate opacity utilities -->
+<div class="bg-blue-500 bg-opacity-75">
+
+<!-- v4: Opacity modifier on the color -->
+<div class="bg-blue-500/75">
+
+<!-- Both opacity and color in one -->
+<div class="bg-blue-500/50 text-white/90 border-gray-300/30">
+```
+
+## Variant Changes
+
+### New Variants
+
+| Variant | Usage | Description |
+|---------|-------|-------------|
+| `@sm:` / `@md:` / `@lg:` | `@md:flex` | Container query breakpoints |
+| `@min-*:` / `@max-*:` | `@min-[400px]:flex` | Arbitrary container queries |
+| `starting:` | `starting:opacity-0` | `@starting-style` for entry animations |
+| `not-*:` | `not-last:mb-4` | Negation pseudo-class |
+| `in-*:` | `in-[.dark]:text-white` | Match within ancestor |
+| `has-*:` | `has-[input:focus]:ring-2` | `:has()` pseudo-class |
+| `nth-*:` | `nth-3:bg-gray-100` | `:nth-child()` |
+| `nth-last-*:` | `nth-last-2:mb-0` | `:nth-last-child()` |
+
+### Removed / Changed Variants
+
+| v3 | v4 | Notes |
+|----|----| ------|
+| `@screen sm` | `@sm` or `@media (width >= 640px)` | `@screen` directive removed |
+| `@variants hover, focus` | Removed | Not needed, variants auto-generated |
+
+### Container Query Variants
+
+```html
+<!-- v4: Native container queries -->
+<div class="@container">
+  <div class="flex flex-col @sm:flex-row @md:grid @md:grid-cols-2 @lg:grid-cols-3 gap-4">
+    <div>Card</div>
+  </div>
+</div>
+
+<!-- Named containers -->
+<div class="@container/main">
+  <div class="@sm/main:flex">Responds to main container</div>
+</div>
+
+<!-- Arbitrary container query values -->
+<div class="@container">
+  <div class="@min-[400px]:flex @max-[800px]:flex-col">
+    Custom breakpoints
+  </div>
+</div>
+```
+
+## @starting-style: Entry Animations
+
+v4 supports `@starting-style` for CSS-native entry animations, eliminating the need for JavaScript animation libraries in many cases.
+
+```css
+/* Dialog with entry animation */
+dialog[open] {
+  opacity: 1;
+  transform: scale(1);
+  transition: opacity 0.3s ease, transform 0.3s ease,
+              display 0.3s ease allow-discrete,
+              overlay 0.3s ease allow-discrete;
+
+  @starting-style {
+    opacity: 0;
+    transform: scale(0.95);
+  }
+}
+
+/* Popover entry */
+[popover]:popover-open {
+  opacity: 1;
+  transform: translateY(0);
+  transition: opacity 0.2s, transform 0.2s,
+              display 0.2s allow-discrete,
+              overlay 0.2s allow-discrete;
+
+  @starting-style {
+    opacity: 0;
+    transform: translateY(-8px);
+  }
+}
+```
+
+### Using with Tailwind v4 Classes
+
+```html
+<!-- The starting: variant maps to @starting-style -->
+<dialog class="opacity-100 scale-100 transition-all duration-300
+               starting:opacity-0 starting:scale-95
+               backdrop:bg-black/50">
+  <div class="p-6">Dialog content</div>
+</dialog>
+```
+
+## Anchor Positioning
+
+v4 supports CSS Anchor Positioning for tooltips, popovers, and dropdowns without JavaScript positioning libraries.
+
+```css
+/* Anchor a tooltip to a button */
+.trigger {
+  anchor-name: --my-trigger;
+}
+
+.tooltip {
+  position: absolute;
+  position-anchor: --my-trigger;
+  top: anchor(bottom);
+  left: anchor(center);
+  transform: translateX(-50%);
+}
+```
+
+## PostCSS Changes
+
+### v3 PostCSS Setup
+
+```js
+// postcss.config.js (v3)
+module.exports = {
+  plugins: {
+    tailwindcss: {},
+    autoprefixer: {},
+  },
+}
+```
+
+### v4 PostCSS Setup
+
+```js
+// postcss.config.js (v4)
+module.exports = {
+  plugins: {
+    '@tailwindcss/postcss': {},
+    // autoprefixer no longer needed - handled by Tailwind v4
+  },
+}
+```
+
+### Package Changes
+
+```bash
+# Remove v3 packages
+npm uninstall tailwindcss postcss autoprefixer
+
+# Install v4 packages
+npm install @tailwindcss/postcss
+```
+
+## Vite Plugin
+
+v4 provides a dedicated Vite plugin that is significantly faster than the PostCSS plugin.
+
+```bash
+npm install @tailwindcss/vite
+```
+
+```js
+// vite.config.js
+import tailwindcss from '@tailwindcss/vite'
+
+export default {
+  plugins: [
+    tailwindcss(),
+  ],
+}
+```
+
+```css
+/* app.css - no postcss config needed */
+@import "tailwindcss";
+
+@theme {
+  --color-brand: #3b82f6;
+}
+```
+
+## CSS Entry Point Changes
+
+### v3 Entry Point
+
+```css
+/* v3 */
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* Custom styles */
+@layer base { ... }
+@layer components { ... }
+@layer utilities { ... }
+```
+
+### v4 Entry Point
+
+```css
+/* v4 */
+@import "tailwindcss";
+
+/* Layers still work the same way */
+@layer base {
+  body {
+    font-family: var(--font-body);
+  }
+}
+
+@layer components {
+  .card {
+    @apply rounded-lg bg-white p-6 shadow-md dark:bg-gray-800;
+  }
+}
+
+@layer utilities {
+  .content-auto {
+    content-visibility: auto;
+  }
+}
+```
+
+## Migration Steps
+
+### 1. Automated Migration Tool
+
+```bash
+# Run the official migration tool
+npx @tailwindcss/upgrade
+```
+
+This tool will:
+- Update `package.json` dependencies
+- Convert `tailwind.config.js` to `@theme` block (where possible)
+- Update CSS entry points (`@tailwind` to `@import`)
+- Replace removed utilities with modern equivalents
+- Update PostCSS config
+
+### 2. Manual Migration Checklist
+
+```
+[ ] Update packages: tailwindcss -> @tailwindcss/postcss (or @tailwindcss/vite)
+[ ] Remove autoprefixer (built into v4)
+[ ] Replace @tailwind directives with @import "tailwindcss"
+[ ] Move tailwind.config.js theme values to @theme block
+    OR use @config "./tailwind.config.js" as compatibility layer
+[ ] Update opacity utilities: bg-opacity-50 -> bg-blue-500/50
+[ ] Update deprecated utility names (flex-shrink -> shrink, etc.)
+[ ] Replace @screen with @media or container queries
+[ ] Update plugins: require() -> @plugin directive
+[ ] Test dark mode (default is now media-based)
+[ ] Verify content detection (automatic in v4, no content config needed)
+[ ] Remove postcss.config.js if using @tailwindcss/vite
+```
+
+### 3. Testing Checklist
+
+```
+[ ] All pages render correctly
+[ ] Dark mode toggle works
+[ ] Responsive breakpoints behave correctly
+[ ] Custom colors / spacing / typography render
+[ ] Animations and transitions work
+[ ] Form styles render (if using @tailwindcss/forms)
+[ ] Prose/typography content renders (if using @tailwindcss/typography)
+[ ] No missing classes in production build
+[ ] No console errors related to CSS
+[ ] Accessibility: focus rings, sr-only text still work
+```
+
+## Breaking Changes by Category
+
+### Configuration
+
+| Change | Impact | Migration |
+|--------|--------|-----------|
+| `tailwind.config.js` no longer auto-detected | Config not loaded | Add `@config "./tailwind.config.js"` or migrate to `@theme` |
+| `content` paths removed | Not needed | v4 automatically detects template files |
+| `safelist` moved | Classes not preserved | Use `@source` directive or CSS comments |
+| `prefix` option | Not directly supported | Use CSS layers or namespacing |
+| `important` option | Changed behavior | Use `@layer` strategy instead |
+
+### Utilities
+
+| Change | Impact | Migration |
+|--------|--------|-----------|
+| `bg-opacity-*` removed | Transparent backgrounds break | Use `/` opacity modifier: `bg-blue-500/75` |
+| `flex-shrink-*` renamed | Warning/removal | Use `shrink-*` |
+| `flex-grow-*` renamed | Warning/removal | Use `grow-*` |
+| Default border color | Was `gray-200`, now `currentColor` | Explicitly set `border-gray-200` |
+| Default ring width | Was `3px`, now `1px` | Explicitly set `ring-3` |
+| Shadow color handling | Different cascade behavior | Test shadow utilities |
+
+### Variants
+
+| Change | Impact | Migration |
+|--------|--------|-----------|
+| `@screen` removed | Compilation error | Use `@media (width >= Xpx)` |
+| `@variants` removed | Compilation error | Remove directive (auto-generated) |
+| `@responsive` removed | Compilation error | Remove directive (auto-generated) |
+| Dark mode default | Was opt-in, now `media` by default | Set `@custom-variant dark` if using class strategy |
+
+### Plugins
+
+| Change | Impact | Migration |
+|--------|--------|-----------|
+| `require()` not supported in CSS | Plugin not loaded | Use `@plugin "package"` |
+| Some plugin APIs changed | Plugin errors | Check plugin compatibility with v4 |
+| `addBase()` behavior | Different layer ordering | Test base styles |
+| `theme()` function | Returns CSS custom properties | Update if doing string comparison |

+ 17 - 2
skills/tool-discovery/SKILL.md

@@ -43,8 +43,17 @@ Is this a reference/lookup task?
 | **sql-ops** | cte, window functions |
 | **postgres-ops** | postgresql, postgres, EXPLAIN ANALYZE, vacuum, pgbouncer, JSONB, RLS, replication |
 | **sqlite-ops** | sqlite, aiosqlite |
-| **tailwind-ops** | tailwind, tw classes |
-| **mcp-ops** | mcp server, protocol |
+| **tailwind-ops** | tailwind, tw classes, dark mode, responsive |
+| **mcp-ops** | mcp server, fastmcp, tool handler, transport |
+| **react-ops** | react, hooks, useState, next.js, RSC, zustand |
+| **vue-ops** | vue, composition api, pinia, nuxt, script setup |
+| **javascript-ops** | javascript, node, esm, async/await, event loop |
+| **astro-ops** | astro, islands, content collections, partial hydration |
+| **laravel-ops** | laravel, eloquent, artisan, sanctum, pest |
+| **nginx-ops** | nginx, reverse proxy, ssl, load balancer, proxy_pass |
+| **auth-ops** | jwt, oauth2, session, rbac, passkey, mfa, login |
+| **monitoring-ops** | prometheus, grafana, opentelemetry, SLO, alerting |
+| **debug-ops** | debug, crash, memory leak, race condition, bisect |
 
 ## Quick Agent Reference
 
@@ -80,6 +89,12 @@ Task tool → subagent_type: "python-expert"
 | "Find files named X" | file-search | Explore |
 | "Set up Python project" | python-env | python-expert |
 | "What HTTP status for X?" | rest-ops | — |
+| "React Server Components?" | react-ops | react-expert |
+| "Vue 3 composable pattern" | vue-ops | vue-expert |
+| "Configure nginx SSL" | nginx-ops | — |
+| "JWT vs session auth" | auth-ops | — |
+| "Set up Prometheus" | monitoring-ops | — |
+| "Debug memory leak" | debug-ops | — |
 
 ## Tips
 

+ 40 - 0
skills/tool-discovery/references/agents-catalog.md

@@ -80,6 +80,44 @@ Complete reference for all available agents in the Task tool.
 
 ---
 
+### go-expert
+
+**Triggers:** golang, go, goroutine, concurrency, channel, context
+
+**Capabilities:**
+- Go idioms and patterns
+- Concurrency (goroutines, channels, errgroup)
+- Error handling and wrapping
+- Testing (table-driven, benchmarks, fuzz)
+- Performance profiling (pprof, trace)
+
+**Best For:**
+- Go architecture decisions
+- Concurrency design
+- Performance optimization
+- Complex error handling
+
+---
+
+### rust-expert
+
+**Triggers:** rust, cargo, ownership, borrow checker, lifetime, tokio
+
+**Capabilities:**
+- Ownership, borrowing, lifetimes
+- Traits, generics, type system
+- Async with tokio
+- Error handling (thiserror, anyhow)
+- Testing and ecosystem
+
+**Best For:**
+- Fighting the borrow checker
+- Async Rust architecture
+- Trait design
+- Unsafe code review
+
+---
+
 ## Framework Experts
 
 ### react-expert
@@ -456,3 +494,5 @@ Complete reference for all available agents in the Task tool.
 | "Fix React performance" | react-expert | - |
 | "Write E2E tests" | cypress-expert | - |
 | "Restructure project" | project-organizer | - |
+| "Go concurrency design" | go-expert | - |
+| "Rust lifetime issues" | rust-expert | - |

+ 170 - 7
skills/tool-discovery/references/skills-catalog.md

@@ -182,17 +182,169 @@ Quick reference for common patterns and syntax.
 
 ---
 
+### react-ops
+
+**Triggers:** react, hooks, useState, useEffect, jsx, tsx, next.js, nextjs, app router, server components, RSC, zustand, react query
+
+**Use For:**
+- Hook patterns (built-in, custom, React 19)
+- Component architecture (compound, render props, HOCs)
+- State management (Context, Zustand, Jotai, Redux Toolkit, TanStack Query)
+- React Server Components, Server Actions, streaming
+- Performance optimization (memo, code splitting, virtualization)
+- Testing (React Testing Library, MSW, Vitest)
+
+**References:** hooks-patterns.md, component-architecture.md, state-management.md, server-components.md, performance.md, testing.md
+
+---
+
+### vue-ops
+
+**Triggers:** vue, vuejs, composition api, pinia, vue router, nuxt, nuxt3, script setup, composable, reactive, defineProps
+
+**Use For:**
+- Composition API and composables
+- Pinia state management
+- Vue Router (guards, lazy loading, meta)
+- Nuxt 3 (SSR/SSG, useFetch, server routes, modules)
+- Testing (Vitest, Vue Test Utils, Pinia testing)
+
+**References:** composition-api.md, state-routing.md, nuxt.md, testing.md
+
+---
+
+### javascript-ops
+
+**Triggers:** javascript, js, node, nodejs, esm, commonjs, promise, async await, event loop, v8, npm, es2024
+
+**Use For:**
+- Async patterns (Promises, async/await, streams, workers)
+- Module systems (ESM, CJS, dual-package)
+- Modern JS features (ES2022-2025)
+- Node.js patterns (built-in test runner, worker_threads, streams)
+- V8 optimization and memory management
+
+**References:** async-patterns.md, modules-runtime.md, modern-features.md, node-patterns.md
+
+---
+
+### astro-ops
+
+**Triggers:** astro, islands architecture, content collections, astro cloudflare, view transitions, partial hydration
+
+**Use For:**
+- Rendering strategies (SSG, SSR, hybrid)
+- Islands architecture and partial hydration
+- Content Collections (schema, queries, MDX)
+- Deployment (Cloudflare, Vercel, Netlify, Node)
+
+**References:** content-collections.md, islands-rendering.md, deployment.md
+
+---
+
+### laravel-ops
+
+**Triggers:** laravel, eloquent, artisan, blade, php, sanctum, livewire, inertia, pest, phpunit
+
+**Use For:**
+- Eloquent ORM (relationships, scopes, query optimization)
+- Architecture (Service Container, providers, facades, middleware)
+- Authentication (Sanctum, Fortify, policies/gates)
+- Testing (Pest, PHPUnit, factories, facade fakes)
+
+**References:** eloquent-queries.md, architecture.md, testing-auth.md
+
+---
+
 ### mcp-ops
 
-**Triggers:** mcp server, model context protocol, tool handlers
+**Triggers:** mcp, model context protocol, mcp server, mcp tool, mcp resource, fastmcp, mcp transport, stdio, sse
+
+**Use For:**
+- MCP server development (Python FastMCP, TypeScript SDK)
+- Tool design (schema, validation, error handling)
+- Resources and prompts
+- Transport configuration (stdio, SSE, streamable HTTP)
+- Authentication and session management
+- Testing and debugging (MCP Inspector)
+
+**References:** server-architecture.md, tool-handlers.md, resources-prompts.md, transport-auth.md, testing-debugging.md
+
+---
+
+### tailwind-ops
+
+**Triggers:** tailwind, tailwindcss, utility classes, responsive design, dark mode, tailwind v4, container queries
+
+**Use For:**
+- Layout patterns (flex, grid, container queries)
+- Responsive design and dark mode
+- Component patterns (cards, forms, navbars, modals)
+- Tailwind v4 migration (CSS-first config, @theme)
+- Configuration and plugins
+
+**References:** component-patterns.md, v4-migration.md, configuration.md
+
+---
+
+## Infrastructure & Operations Skills
+
+### nginx-ops
+
+**Triggers:** nginx, reverse proxy, load balancer, proxy_pass, ssl certificate, lets encrypt, web server
+
+**Use For:**
+- Reverse proxy configuration and load balancing
+- SSL/TLS setup (Let's Encrypt, HSTS, OCSP)
+- Security headers and rate limiting
+- Performance tuning (gzip, caching, worker config)
+- Docker patterns (nginx as sidecar)
+
+**References:** reverse-proxy.md, ssl-security.md, performance.md
+
+---
+
+### auth-ops
+
+**Triggers:** authentication, authorization, jwt, oauth, oauth2, session, login, rbac, abac, passkey, mfa, api key
+
+**Use For:**
+- Authentication methods (JWT, sessions, OAuth2, passkeys)
+- Authorization models (RBAC, ABAC, ReBAC)
+- OAuth2/OIDC flows (Authorization Code + PKCE, Client Credentials)
+- Password handling, MFA, session management
+- Implementation patterns across Node.js, Python, Go
+
+**References:** jwt-sessions.md, oauth2-oidc.md, authorization.md, implementation.md
+
+---
+
+### monitoring-ops
+
+**Triggers:** monitoring, observability, prometheus, grafana, metrics, alerting, opentelemetry, SLO, distributed tracing
+
+**Use For:**
+- Metrics (Prometheus, PromQL, OpenTelemetry)
+- Structured logging (Loki, ELK, language-specific)
+- Distributed tracing (OpenTelemetry, Jaeger)
+- Alerting and SLO/SLI design
+- Infrastructure monitoring and health checks
+
+**References:** metrics-alerting.md, logging.md, tracing.md, infrastructure.md
+
+---
+
+### debug-ops
+
+**Triggers:** debug, debugging, bug, crash, memory leak, race condition, deadlock, bisect, root cause, profiling
 
 **Use For:**
-- MCP server structure
-- Tool handler patterns
-- Resource configuration
-- Protocol implementation
+- Systematic debugging methodology
+- Language-specific debugger usage (Node, Python, Go, Rust)
+- Common scenarios (memory leaks, deadlocks, race conditions)
+- Root cause analysis and reproduction techniques
 
-**References:** server-patterns.md, tool-handlers.md, resources.md
+**References:** systematic-methods.md, tool-specific.md, common-scenarios.md
 
 ---
 
@@ -350,12 +502,19 @@ Project and development workflow automation.
 | SQL databases | sql-ops, postgres-ops, sqlite-ops |
 | Go | go-ops |
 | Rust | rust-ops |
-| TypeScript/JS | typescript-ops, file-search, structural-search |
+| TypeScript/JS | typescript-ops, javascript-ops, file-search, structural-search |
+| React/Next.js | react-ops, typescript-ops |
+| Vue/Nuxt | vue-ops, typescript-ops |
+| Astro | astro-ops, typescript-ops |
+| PHP/Laravel | laravel-ops |
 | Python | python-env, structural-search |
 | API design | api-design-ops, rest-ops |
 | Docker/containers | docker-ops, container-orchestration |
 | CI/CD | ci-cd-ops, git-workflow |
 | CSS/Tailwind | tailwind-ops |
+| Nginx/web server | nginx-ops |
+| Auth/security | auth-ops, security-ops |
+| Monitoring | monitoring-ops, python-observability-ops |
 
 ### By Task
 
@@ -372,6 +531,10 @@ Project and development workflow automation.
 | Run project tasks | task-runner |
 | Find project docs | doc-scanner |
 | Plan implementation | project-planner |
+| Debug a crash/leak | debug-ops |
+| Configure nginx | nginx-ops |
+| Set up auth | auth-ops |
+| Add monitoring | monitoring-ops |
 
 ### By Complexity
 

+ 436 - 0
skills/vue-ops/SKILL.md

@@ -0,0 +1,436 @@
+---
+name: vue-ops
+description: "Vue 3 development patterns, Composition API, Pinia state management, Vue Router, and Nuxt 3. Use for: vue, vuejs, composition api, pinia, vue router, nuxt, nuxt3, script setup, composable, reactive, defineProps, defineEmits, defineModel, v-model, provide inject, vue3."
+allowed-tools: "Read Write Bash"
+related-skills: [typescript-ops, testing-ops, tailwind-ops, javascript-ops]
+---
+
+# Vue Operations
+
+Comprehensive Vue 3 reference covering Composition API, Pinia, Vue Router, Nuxt 3, and testing — production patterns with TypeScript throughout.
+
+---
+
+## Reactivity Decision Tree
+
+```
+What data do I need to make reactive?
+│
+├─ A single primitive (string, number, boolean)?
+│   └─ ref()
+│       const count = ref(0)
+│       const name = ref('')
+│
+├─ A plain object or array with deep reactivity?
+│   ├─ Will I destructure it or pass properties individually?
+│   │   └─ reactive() — but use toRefs() when destructuring
+│   └─ Will I replace the whole object at once?
+│       └─ ref() — ref.value = newObject
+│
+├─ Derived/computed state from other reactive sources?
+│   └─ computed()
+│       const doubled = computed(() => count.value * 2)
+│
+├─ A large object where only top-level keys change?
+│   └─ shallowRef() or shallowReactive()
+│       const state = shallowRef({ nested: { big: 'data' } })
+│
+├─ Side effects that should run when dependencies change?
+│   ├─ Don't need to know old value, auto-tracks dependencies?
+│   │   └─ watchEffect(() => { ... })
+│   └─ Need old/new values, explicit sources, or lazy execution?
+│       └─ watch(source, (newVal, oldVal) => { ... })
+│
+└─ Data that should NOT be reactive (raw DOM, third-party instances)?
+    └─ markRaw(obj) or shallowRef(obj)
+```
+
+---
+
+## Component Communication Decision Tree
+
+```
+How far does data need to travel?
+│
+├─ Parent → direct child?
+│   └─ props (defineProps)
+│       Direct, explicit, type-safe
+│
+├─ Child → parent (user action / data update)?
+│   └─ emit (defineEmits)
+│       defineEmits<{ change: [value: string] }>()
+│
+├─ Parent ↔ child bidirectional binding?
+│   └─ v-model via defineModel() (Vue 3.4+)
+│       const model = defineModel<string>()
+│
+├─ Ancestor → deep descendant (prop drilling problem)?
+│   └─ provide / inject
+│       Use InjectionKey<T> for type safety
+│
+├─ Siblings or unrelated components?
+│   ├─ Simple/few shared values?
+│   │   └─ provide / inject from a common ancestor
+│   └─ Complex shared state or cross-tree communication?
+│       └─ Pinia store
+│
+├─ Truly global state (user session, cart, preferences)?
+│   └─ Pinia store
+│       defineStore with setup syntax
+│
+└─ One-time events between distant components (rare)?
+    └─ Pinia action + watch, or mitt event bus
+        Avoid: Vue removed $emit on root in Vue 3
+```
+
+---
+
+## Composition API Quick Reference
+
+### `<script setup>` — the standard
+
+```vue
+<script setup lang="ts">
+import { ref, computed, watch, onMounted } from 'vue'
+
+// Props — with TypeScript generics (no runtime declaration needed)
+const props = defineProps<{
+  title: string
+  count?: number
+}>()
+
+// Props with defaults
+const props = withDefaults(defineProps<{
+  size: 'sm' | 'md' | 'lg'
+  disabled?: boolean
+}>(), {
+  size: 'md',
+  disabled: false,
+})
+
+// Emits — type-safe event signatures
+const emit = defineEmits<{
+  change: [value: string]        // named tuple syntax (Vue 3.3+)
+  update: [id: number, data: object]
+  close: []
+}>()
+
+// Reactive state
+const count = ref(0)
+const user = reactive({ name: '', email: '' })
+
+// Computed
+const doubled = computed(() => count.value * 2)
+
+// Watch
+watch(count, (newVal, oldVal) => {
+  console.log(`count changed from ${oldVal} to ${newVal}`)
+})
+
+// Lifecycle
+onMounted(() => {
+  console.log('component mounted')
+})
+</script>
+```
+
+### `defineModel` — v-model binding (Vue 3.4+)
+
+```vue
+<!-- Child component: MyInput.vue -->
+<script setup lang="ts">
+const model = defineModel<string>({ required: true })
+
+// Named v-model: <MyInput v-model:title="..." />
+const title = defineModel<string>('title')
+
+// With modifiers
+const [modelValue, modifiers] = defineModel<string, 'trim' | 'uppercase'>()
+</script>
+
+<template>
+  <input :value="model" @input="model = $event.target.value" />
+</template>
+```
+
+### `defineExpose` — expose to parent refs
+
+```vue
+<script setup lang="ts">
+const inputRef = ref<HTMLInputElement | null>(null)
+
+function focus() {
+  inputRef.value?.focus()
+}
+
+// Expose public API for parent template refs
+defineExpose({ focus })
+</script>
+```
+
+### `defineOptions` — component meta (Vue 3.3+)
+
+```vue
+<script setup lang="ts">
+defineOptions({
+  name: 'MyComponent',
+  inheritAttrs: false,
+})
+</script>
+```
+
+### `defineSlots` — type slots (Vue 3.3+)
+
+```vue
+<script setup lang="ts">
+defineSlots<{
+  default(props: { item: User }): any
+  header(props: {}): any
+}>()
+</script>
+```
+
+---
+
+## Pinia Quick Start
+
+### Setup syntax (recommended — composable style)
+
+```ts
+// stores/counter.ts
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+
+export const useCounterStore = defineStore('counter', () => {
+  // state
+  const count = ref(0)
+  const name = ref('Counter')
+
+  // getters
+  const doubled = computed(() => count.value * 2)
+
+  // actions
+  function increment() {
+    count.value++
+  }
+
+  async function fetchData() {
+    const data = await api.get('/data')
+    count.value = data.total
+  }
+
+  return { count, name, doubled, increment, fetchData }
+})
+```
+
+### Options syntax
+
+```ts
+export const useCounterStore = defineStore('counter', {
+  state: () => ({ count: 0 }),
+  getters: {
+    doubled: (state) => state.count * 2,
+  },
+  actions: {
+    increment() { this.count++ },
+  },
+})
+```
+
+### Using stores in components
+
+```vue
+<script setup lang="ts">
+import { storeToRefs } from 'pinia'
+import { useCounterStore } from '@/stores/counter'
+
+const store = useCounterStore()
+
+// storeToRefs preserves reactivity when destructuring state/getters
+// Actions can be destructured directly (they're not reactive)
+const { count, doubled } = storeToRefs(store)
+const { increment } = store
+</script>
+```
+
+### Pinia plugins — persistence example
+
+```ts
+// main.ts
+import { createPinia } from 'pinia'
+import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
+
+const pinia = createPinia()
+pinia.use(piniaPluginPersistedstate)
+
+// In store:
+export const useAuthStore = defineStore('auth', () => { ... }, {
+  persist: true, // or { storage: sessionStorage, paths: ['token'] }
+})
+```
+
+---
+
+## Vue Router Quick Reference
+
+### Basic configuration
+
+```ts
+// router/index.ts
+import { createRouter, createWebHistory } from 'vue-router'
+
+const router = createRouter({
+  history: createWebHistory(import.meta.env.BASE_URL),
+  routes: [
+    {
+      path: '/',
+      name: 'home',
+      component: () => import('@/views/HomeView.vue'), // lazy load
+    },
+    {
+      path: '/users/:id',
+      name: 'user',
+      component: () => import('@/views/UserView.vue'),
+      props: true,                    // passes :id as prop
+      meta: { requiresAuth: true },
+    },
+    {
+      path: '/admin',
+      component: () => import('@/layouts/AdminLayout.vue'),
+      children: [
+        { path: '', component: () => import('@/views/admin/Dashboard.vue') },
+        { path: 'users', component: () => import('@/views/admin/Users.vue') },
+      ],
+    },
+    { path: '/:pathMatch(.*)*', name: 'not-found', component: NotFound },
+  ],
+  scrollBehavior(to, from, savedPosition) {
+    if (savedPosition) return savedPosition
+    if (to.hash) return { el: to.hash, behavior: 'smooth' }
+    return { top: 0 }
+  },
+})
+
+export default router
+```
+
+### Navigation guards
+
+```ts
+// Global guard — auth check
+router.beforeEach((to, from) => {
+  const auth = useAuthStore()
+  if (to.meta.requiresAuth && !auth.isLoggedIn) {
+    return { name: 'login', query: { redirect: to.fullPath } }
+  }
+})
+
+// Per-route guard
+{
+  path: '/admin',
+  beforeEnter: (to, from) => {
+    if (!isAdmin()) return { name: 'forbidden' }
+  },
+}
+```
+
+```vue
+<!-- In-component guard -->
+<script setup lang="ts">
+import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
+
+onBeforeRouteLeave((to, from) => {
+  if (hasUnsavedChanges.value) {
+    return confirm('Leave without saving?')
+  }
+})
+</script>
+```
+
+### TypeScript meta typing
+
+```ts
+// router/index.ts — augment RouteMeta
+declare module 'vue-router' {
+  interface RouteMeta {
+    requiresAuth?: boolean
+    title?: string
+    breadcrumb?: string
+  }
+}
+```
+
+---
+
+## Nuxt 3 Decision Tree
+
+```
+What rendering strategy does my app need?
+│
+├─ Public content (blogs, marketing, docs)?
+│   ├─ Content rarely changes (< daily)?
+│   │   └─ SSG — prerender: { routes: ['/', '/about'] }
+│   └─ Content updated frequently?
+│       └─ ISR — routeRules: { '/blog/**': { isr: 3600 } }
+│
+├─ Dynamic per-user content (dashboards, apps)?
+│   └─ SSR — ssr: true (Nuxt default)
+│       Best for SEO + authenticated data
+│
+├─ Admin panel / internal tool (no SEO needed)?
+│   └─ SPA — ssr: false in nuxt.config.ts
+│
+├─ Mixed needs (marketing pages + app)?
+│   └─ Hybrid — routeRules per path
+│       routeRules: {
+│         '/': { prerender: true },
+│         '/blog/**': { isr: 3600 },
+│         '/app/**': { ssr: true },
+│         '/admin/**': { ssr: false },
+│       }
+│
+└─ Deploying to...
+    ├─ Cloudflare Workers/Pages → preset: 'cloudflare'
+    ├─ Vercel → preset: 'vercel' (auto-detected)
+    ├─ Netlify → preset: 'netlify' (auto-detected)
+    └─ Node.js server → preset: 'node-server'
+```
+
+---
+
+## Common Gotchas
+
+| Gotcha | Why | Fix |
+|--------|-----|-----|
+| Reactivity lost after destructuring `reactive()` | Destructuring extracts plain values, not refs | Use `toRefs(state)` when destructuring, or use `ref()` instead of `reactive()` |
+| `ref.value` needed in `<script>`, not in `<template>` | Template auto-unwraps top-level refs | Access as `count` in template, `count.value` in script |
+| `watch` doesn't fire on nested object changes | Default is shallow watch | Add `{ deep: true }` or watch a specific nested path `() => obj.nested.prop` |
+| Async setup breaks SSR in Nuxt | `await` in `setup()` suspends the component | Use `useAsyncData` or `useFetch` — never raw `await fetch()` in Nuxt setup |
+| `watchEffect` runs immediately and tracks lazily | Tracks dependencies at runtime, not statically | Use `watch` with explicit sources when you need control over what's tracked |
+| Template refs are `null` before mount | `ref()` is null until component is mounted | Access template refs inside `onMounted` or use `watch` with `{ immediate: false }` |
+| Pinia store state lost when destructuring | State properties are not reactive when pulled out directly | Always use `storeToRefs(store)` for state/getters; destructure actions directly |
+| Props are readonly — mutating causes warning | Vue enforces one-way data flow | Emit event to parent and let parent update; or use `defineModel()` for two-way binding |
+| `computed` setter not called on direct assignment | Computed with no setter is read-only by default | Define `get` and `set`: `computed({ get: () => ..., set: (v) => ... })` |
+| `v-model` on component uses wrong prop/event name | Default v-model uses `modelValue` prop and `update:modelValue` event | Use `defineModel()` (Vue 3.4+) or manually wire `modelValue` prop + `update:modelValue` emit |
+| `provide` value is not reactive | Providing a raw value instead of a ref | Provide `ref()` or `reactive()` so injectors see updates: `provide('key', ref(value))` |
+| `defineAsyncComponent` error not caught | Async component rejects without error boundary | Add `errorComponent` option or wrap in `<Suspense>` with error slot |
+
+---
+
+## Reference Files
+
+| File | When to Load |
+|------|-------------|
+| [./references/composition-api.md](./references/composition-api.md) | Composables, provide/inject, template refs, custom directives, Teleport, Suspense, slots, transitions, v-model deep patterns |
+| [./references/state-routing.md](./references/state-routing.md) | Pinia advanced patterns (plugins, SSR, store composition), Vue Router (guards, meta typing, scroll behavior, transitions) |
+| [./references/nuxt.md](./references/nuxt.md) | Nuxt 3 data fetching, server routes, middleware, plugins, modules, SEO, deployment, Nuxt Content |
+| [./references/testing.md](./references/testing.md) | Vitest setup, Vue Test Utils, Pinia/Router testing, composable testing, MSW, Playwright, Nuxt test utils |
+
+---
+
+## See Also
+
+- **typescript-ops** — TypeScript generics, utility types, strict mode configuration
+- **testing-ops** — General testing patterns, TDD, mocking strategies, CI integration
+- **tailwind-ops** — Tailwind CSS with Vue component patterns, dark mode, responsive design
+- **javascript-ops** — Modern JS patterns used alongside Vue (async/await, modules, iterators)

+ 0 - 0
skills/vue-ops/assets/.gitkeep


+ 837 - 0
skills/vue-ops/references/composition-api.md

@@ -0,0 +1,837 @@
+# Composition API Reference
+
+Deep-dive patterns for Vue 3 Composition API: composables, lifecycle, template refs, provide/inject, v-model, slots, transitions, Teleport, Suspense, and custom directives.
+
+---
+
+## Composables
+
+### Naming and structure convention
+
+```ts
+// composables/useCounter.ts
+import { ref, computed, onUnmounted } from 'vue'
+
+// Rule: always prefix with "use"
+export function useCounter(initialValue = 0) {
+  // State: return refs so callers can destructure while keeping reactivity
+  const count = ref(initialValue)
+  const isNegative = computed(() => count.value < 0)
+
+  function increment() { count.value++ }
+  function decrement() { count.value-- }
+  function reset() { count.value = initialValue }
+
+  // Cleanup: always handle in onUnmounted if you register listeners/timers
+  // (onUnmounted is a no-op when called outside a component)
+
+  return { count, isNegative, increment, decrement, reset }
+}
+```
+
+### Accepting refs as arguments (reactive composable inputs)
+
+```ts
+// composables/useDouble.ts
+import { computed, toRef, MaybeRefOrGetter, toValue } from 'vue'
+
+// toValue() (Vue 3.3+) unwraps ref, getter, or raw value
+export function useDouble(value: MaybeRefOrGetter<number>) {
+  return computed(() => toValue(value) * 2)
+}
+
+// Usage: works with raw value, ref, or getter
+const x = ref(5)
+const doubled = useDouble(x)          // reactive
+const doubled2 = useDouble(5)         // static
+const doubled3 = useDouble(() => x.value + 1)  // getter
+```
+
+### useFetch — data fetching with cancellation
+
+```ts
+// composables/useFetch.ts
+import { ref, watchEffect, toValue, MaybeRefOrGetter } from 'vue'
+
+export function useFetch<T>(url: MaybeRefOrGetter<string>) {
+  const data = ref<T | null>(null)
+  const error = ref<Error | null>(null)
+  const pending = ref(false)
+
+  watchEffect((onCleanup) => {
+    const controller = new AbortController()
+
+    // Register cleanup BEFORE the async work
+    onCleanup(() => controller.abort())
+
+    pending.value = true
+    error.value = null
+
+    fetch(toValue(url), { signal: controller.signal })
+      .then((res) => {
+        if (!res.ok) throw new Error(`HTTP ${res.status}`)
+        return res.json()
+      })
+      .then((json) => { data.value = json })
+      .catch((err) => {
+        if (err.name !== 'AbortError') error.value = err
+      })
+      .finally(() => { pending.value = false })
+  })
+
+  return { data, error, pending }
+}
+```
+
+### useLocalStorage — synced persistent state
+
+```ts
+// composables/useLocalStorage.ts
+import { ref, watch } from 'vue'
+
+export function useLocalStorage<T>(key: string, defaultValue: T) {
+  const stored = localStorage.getItem(key)
+  const initial = stored ? (JSON.parse(stored) as T) : defaultValue
+  const state = ref<T>(initial)
+
+  watch(
+    state,
+    (value) => localStorage.setItem(key, JSON.stringify(value)),
+    { deep: true }
+  )
+
+  return state
+}
+
+// Usage
+const theme = useLocalStorage<'light' | 'dark'>('theme', 'light')
+```
+
+### useEventListener — safe event binding
+
+```ts
+// composables/useEventListener.ts
+import { onMounted, onUnmounted, isRef, watch } from 'vue'
+import type { Ref } from 'vue'
+
+export function useEventListener<K extends keyof WindowEventMap>(
+  target: Window | Document | Ref<HTMLElement | null>,
+  event: K,
+  handler: (e: WindowEventMap[K]) => void
+) {
+  if (isRef(target)) {
+    watch(target, (el, _, onCleanup) => {
+      el?.addEventListener(event, handler as EventListener)
+      onCleanup(() => el?.removeEventListener(event, handler as EventListener))
+    })
+  } else {
+    onMounted(() => target.addEventListener(event, handler as EventListener))
+    onUnmounted(() => target.removeEventListener(event, handler as EventListener))
+  }
+}
+
+// Usage
+useEventListener(window, 'resize', () => {
+  console.log('window resized')
+})
+```
+
+### useDark — dark mode toggle
+
+```ts
+// composables/useDark.ts
+import { ref, watch, onMounted } from 'vue'
+
+export function useDark() {
+  const isDark = ref(false)
+
+  onMounted(() => {
+    isDark.value = document.documentElement.classList.contains('dark')
+      || window.matchMedia('(prefers-color-scheme: dark)').matches
+  })
+
+  watch(isDark, (dark) => {
+    document.documentElement.classList.toggle('dark', dark)
+  })
+
+  function toggle() { isDark.value = !isDark.value }
+
+  return { isDark, toggle }
+}
+```
+
+### useIntersectionObserver — lazy loading / scroll tracking
+
+```ts
+// composables/useIntersectionObserver.ts
+import { ref, onMounted, onUnmounted } from 'vue'
+import type { Ref } from 'vue'
+
+export function useIntersectionObserver(
+  target: Ref<HTMLElement | null>,
+  options: IntersectionObserverInit = {}
+) {
+  const isIntersecting = ref(false)
+  let observer: IntersectionObserver | null = null
+
+  onMounted(() => {
+    observer = new IntersectionObserver(([entry]) => {
+      isIntersecting.value = entry.isIntersecting
+    }, options)
+
+    if (target.value) observer.observe(target.value)
+  })
+
+  onUnmounted(() => observer?.disconnect())
+
+  return { isIntersecting }
+}
+
+// Usage
+const el = ref<HTMLElement | null>(null)
+const { isIntersecting } = useIntersectionObserver(el, { threshold: 0.1 })
+```
+
+---
+
+## Lifecycle Hooks
+
+```ts
+import {
+  onBeforeMount,   // before first render, DOM not yet created
+  onMounted,       // after first render, DOM available
+  onBeforeUpdate,  // before re-render triggered by reactive change
+  onUpdated,       // after re-render (DOM updated)
+  onBeforeUnmount, // before component teardown (still fully functional)
+  onUnmounted,     // after component teardown
+  onActivated,     // component re-activated inside <KeepAlive>
+  onDeactivated,   // component deactivated inside <KeepAlive>
+  onErrorCaptured, // error from descendant component
+} from 'vue'
+
+// Pattern: separate concerns into multiple onMounted calls
+onMounted(() => { initChart() })
+onMounted(() => { attachKeyboardListeners() })
+
+// KeepAlive lifecycle — fetch fresh data on each activation
+onActivated(() => { refreshData() })
+onDeactivated(() => { pauseAnimations() })
+
+// Error boundary at composable level
+onErrorCaptured((err, instance, info) => {
+  logError(err)
+  return false // prevent propagation
+})
+```
+
+---
+
+## Template Refs
+
+### Basic ref() approach
+
+```vue
+<script setup lang="ts">
+import { ref, onMounted } from 'vue'
+
+const inputEl = ref<HTMLInputElement | null>(null)
+
+onMounted(() => {
+  inputEl.value?.focus()
+})
+</script>
+
+<template>
+  <input ref="inputEl" type="text" />
+</template>
+```
+
+### useTemplateRef() — Vue 3.5+
+
+```vue
+<script setup lang="ts">
+import { useTemplateRef, onMounted } from 'vue'
+
+// String key matches the ref="..." attribute in template
+const input = useTemplateRef<HTMLInputElement>('inputEl')
+
+onMounted(() => {
+  input.value?.focus()
+})
+</script>
+
+<template>
+  <input ref="inputEl" type="text" />
+</template>
+```
+
+### Component refs — accessing exposed methods
+
+```vue
+<!-- Parent -->
+<script setup lang="ts">
+import { ref } from 'vue'
+import type ChildComponent from './ChildComponent.vue'
+
+const child = ref<InstanceType<typeof ChildComponent> | null>(null)
+
+function focusChild() {
+  child.value?.focus() // only works if child uses defineExpose
+}
+</script>
+
+<template>
+  <ChildComponent ref="child" />
+</template>
+```
+
+```vue
+<!-- ChildComponent.vue -->
+<script setup lang="ts">
+import { ref } from 'vue'
+
+const inputEl = ref<HTMLInputElement | null>(null)
+
+function focus() {
+  inputEl.value?.focus()
+}
+
+defineExpose({ focus })
+</script>
+```
+
+### Dynamic template refs in v-for
+
+```vue
+<script setup lang="ts">
+import { ref } from 'vue'
+
+const itemRefs = ref<HTMLElement[]>([])
+const items = ref(['a', 'b', 'c'])
+</script>
+
+<template>
+  <ul>
+    <li
+      v-for="item in items"
+      :key="item"
+      :ref="(el) => { if (el) itemRefs.push(el as HTMLElement) }"
+    >
+      {{ item }}
+    </li>
+  </ul>
+</template>
+```
+
+---
+
+## provide / inject
+
+### Typed injection keys (InjectionKey<T>)
+
+```ts
+// keys/injection-keys.ts
+import { InjectionKey, Ref } from 'vue'
+
+export interface UserContext {
+  user: Ref<User | null>
+  logout: () => void
+}
+
+// The key carries the type — no casts needed at inject site
+export const UserContextKey: InjectionKey<UserContext> = Symbol('UserContext')
+```
+
+### Providing values (ancestor component)
+
+```vue
+<!-- App.vue or layout component -->
+<script setup lang="ts">
+import { provide, ref, readonly } from 'vue'
+import { UserContextKey } from '@/keys/injection-keys'
+import type { User } from '@/types'
+
+const user = ref<User | null>(null)
+
+function logout() {
+  user.value = null
+}
+
+// Wrap in readonly to prevent descendants from mutating directly
+provide(UserContextKey, { user: readonly(user), logout })
+</script>
+```
+
+### Injecting in descendants
+
+```vue
+<script setup lang="ts">
+import { inject } from 'vue'
+import { UserContextKey } from '@/keys/injection-keys'
+
+// TypeScript knows the type from the InjectionKey
+const ctx = inject(UserContextKey)
+// ctx is UserContext | undefined — handle the undefined case
+
+// With default value (ensures non-null)
+const ctx2 = inject(UserContextKey, {
+  user: ref(null),
+  logout: () => {},
+})
+</script>
+```
+
+---
+
+## v-model Patterns
+
+### defineModel() — Vue 3.4+
+
+```vue
+<!-- SimpleInput.vue -->
+<script setup lang="ts">
+// Single v-model — replaces modelValue prop + update:modelValue emit
+const model = defineModel<string>({ required: true })
+</script>
+
+<template>
+  <input :value="model" @input="model = ($event.target as HTMLInputElement).value" />
+</template>
+```
+
+```vue
+<!-- Parent usage -->
+<SimpleInput v-model="username" />
+```
+
+### Multiple v-models
+
+```vue
+<!-- RangeInput.vue -->
+<script setup lang="ts">
+const min = defineModel<number>('min', { default: 0 })
+const max = defineModel<number>('max', { default: 100 })
+</script>
+
+<template>
+  <input type="number" :value="min" @input="min = +($event.target as HTMLInputElement).value" />
+  <input type="number" :value="max" @input="max = +($event.target as HTMLInputElement).value" />
+</template>
+```
+
+```vue
+<!-- Parent usage -->
+<RangeInput v-model:min="rangeMin" v-model:max="rangeMax" />
+```
+
+### v-model with modifiers
+
+```vue
+<!-- UpperInput.vue -->
+<script setup lang="ts">
+const [model, modifiers] = defineModel<string, 'uppercase' | 'trim'>({
+  set(value) {
+    if (modifiers.trim) value = value.trim()
+    if (modifiers.uppercase) value = value.toUpperCase()
+    return value
+  }
+})
+</script>
+```
+
+```vue
+<!-- Parent usage -->
+<UpperInput v-model.uppercase.trim="text" />
+```
+
+---
+
+## Slots
+
+### Named slots with TypeScript types
+
+```vue
+<!-- DataTable.vue -->
+<script setup lang="ts">
+defineSlots<{
+  default?: (props: {}) => any
+  header?: (props: { title: string }) => any
+  row: (props: { item: User; index: number }) => any
+  empty?: (props: {}) => any
+}>()
+
+const props = defineProps<{ items: User[] }>()
+</script>
+
+<template>
+  <div>
+    <slot name="header" :title="'Users'" />
+    <div v-if="props.items.length === 0">
+      <slot name="empty" />
+    </div>
+    <div v-for="(item, index) in props.items" :key="item.id">
+      <slot name="row" :item="item" :index="index" />
+    </div>
+    <slot />
+  </div>
+</template>
+```
+
+```vue
+<!-- Parent usage — scoped slot destructuring -->
+<DataTable :items="users">
+  <template #header="{ title }">
+    <h2>{{ title }}</h2>
+  </template>
+  <template #row="{ item, index }">
+    <div>{{ index + 1 }}. {{ item.name }}</div>
+  </template>
+  <template #empty>
+    <p>No users found.</p>
+  </template>
+</DataTable>
+```
+
+### Renderless components
+
+```vue
+<!-- Renderless: MouseTracker.vue -->
+<script setup lang="ts">
+import { ref } from 'vue'
+import { useEventListener } from '@/composables/useEventListener'
+
+const x = ref(0)
+const y = ref(0)
+
+useEventListener(window, 'mousemove', (e) => {
+  x.value = e.clientX
+  y.value = e.clientY
+})
+</script>
+
+<template>
+  <!-- Only renders what's in the default scoped slot -->
+  <slot :x="x" :y="y" />
+</template>
+```
+
+```vue
+<!-- Usage -->
+<MouseTracker v-slot="{ x, y }">
+  Cursor: {{ x }}, {{ y }}
+</MouseTracker>
+```
+
+### useSlots() in composables
+
+```ts
+import { useSlots, computed } from 'vue'
+
+// Check if a named slot is provided
+export function useHasSlot(name: string) {
+  const slots = useSlots()
+  return computed(() => !!slots[name])
+}
+```
+
+---
+
+## Transitions
+
+### CSS transitions
+
+```vue
+<script setup lang="ts">
+import { ref } from 'vue'
+const show = ref(true)
+</script>
+
+<template>
+  <button @click="show = !show">Toggle</button>
+
+  <Transition name="fade">
+    <div v-if="show" class="box">Hello</div>
+  </Transition>
+</template>
+
+<style scoped>
+.fade-enter-active,
+.fade-leave-active {
+  transition: opacity 0.3s ease;
+}
+
+.fade-enter-from,
+.fade-leave-to {
+  opacity: 0;
+}
+</style>
+```
+
+### JavaScript hooks (GSAP / Web Animations API)
+
+```vue
+<template>
+  <Transition
+    @before-enter="onBeforeEnter"
+    @enter="onEnter"
+    @leave="onLeave"
+    :css="false"
+  >
+    <div v-if="show" />
+  </Transition>
+</template>
+
+<script setup lang="ts">
+import gsap from 'gsap'
+
+function onBeforeEnter(el: Element) {
+  gsap.set(el, { opacity: 0, y: -20 })
+}
+
+function onEnter(el: Element, done: () => void) {
+  gsap.to(el, { opacity: 1, y: 0, duration: 0.4, onComplete: done })
+}
+
+function onLeave(el: Element, done: () => void) {
+  gsap.to(el, { opacity: 0, y: 20, duration: 0.3, onComplete: done })
+}
+</script>
+```
+
+### TransitionGroup — list animations
+
+```vue
+<template>
+  <TransitionGroup name="list" tag="ul">
+    <li v-for="item in items" :key="item.id">
+      {{ item.name }}
+    </li>
+  </TransitionGroup>
+</template>
+
+<style>
+.list-enter-active,
+.list-leave-active {
+  transition: all 0.3s ease;
+}
+.list-enter-from {
+  opacity: 0;
+  transform: translateX(-30px);
+}
+.list-leave-to {
+  opacity: 0;
+  transform: translateX(30px);
+}
+/* Animate position changes of remaining items */
+.list-move {
+  transition: transform 0.3s ease;
+}
+/* Ensure leaving items take up no space during animation */
+.list-leave-active {
+  position: absolute;
+}
+</style>
+```
+
+---
+
+## Teleport
+
+### Modal pattern
+
+```vue
+<!-- Modal.vue -->
+<script setup lang="ts">
+defineProps<{ open: boolean }>()
+const emit = defineEmits<{ close: [] }>()
+</script>
+
+<template>
+  <Teleport to="body">
+    <Transition name="fade">
+      <div v-if="open" class="modal-overlay" @click.self="emit('close')">
+        <div class="modal-content" role="dialog" aria-modal="true">
+          <slot />
+          <button @click="emit('close')">Close</button>
+        </div>
+      </div>
+    </Transition>
+  </Teleport>
+</template>
+```
+
+### Disabling Teleport conditionally
+
+```vue
+<!-- Disable teleport in SSR or based on prop -->
+<Teleport to="#modals" :disabled="!isMounted">
+  <div>Content</div>
+</Teleport>
+```
+
+---
+
+## Suspense
+
+### Async setup with Suspense
+
+```vue
+<!-- AsyncUserProfile.vue — top-level await allowed in <script setup> -->
+<script setup lang="ts">
+const { data: user } = await useFetch<User>('/api/user')
+//     ^ Component is now async — must be wrapped in <Suspense>
+</script>
+
+<template>
+  <div>{{ user?.name }}</div>
+</template>
+```
+
+```vue
+<!-- Parent wraps async component -->
+<template>
+  <Suspense>
+    <template #default>
+      <AsyncUserProfile />
+    </template>
+    <template #fallback>
+      <div class="skeleton" aria-busy="true">Loading...</div>
+    </template>
+  </Suspense>
+</template>
+```
+
+### Error handling with Suspense
+
+```vue
+<script setup lang="ts">
+import { ref } from 'vue'
+
+const error = ref<Error | null>(null)
+
+function handleError(e: Error) {
+  error.value = e
+}
+</script>
+
+<template>
+  <div v-if="error">Error: {{ error.message }}</div>
+  <Suspense v-else @resolve="onResolved" @fallback="onFallback" @pending="onPending">
+    <AsyncComponent />
+    <template #fallback>Loading...</template>
+  </Suspense>
+</template>
+```
+
+---
+
+## Custom Directives
+
+### vFocus — auto-focus on mount
+
+```ts
+// directives/vFocus.ts
+import type { Directive } from 'vue'
+
+export const vFocus: Directive<HTMLElement> = {
+  mounted(el) {
+    el.focus()
+  }
+}
+```
+
+```vue
+<script setup lang="ts">
+import { vFocus } from '@/directives/vFocus'
+// Directives imported in <script setup> are automatically available
+</script>
+
+<template>
+  <input v-focus type="text" />
+</template>
+```
+
+### vClickOutside — dismiss on outside click
+
+```ts
+// directives/vClickOutside.ts
+import type { Directive } from 'vue'
+
+type ClickOutsideHandler = (event: MouseEvent) => void
+
+export const vClickOutside: Directive<HTMLElement, ClickOutsideHandler> = {
+  mounted(el, binding) {
+    el._clickOutside = (event: MouseEvent) => {
+      if (!el.contains(event.target as Node)) {
+        binding.value(event)
+      }
+    }
+    document.addEventListener('click', el._clickOutside)
+  },
+  unmounted(el) {
+    document.removeEventListener('click', el._clickOutside)
+    delete el._clickOutside
+  },
+}
+```
+
+### vIntersect — visibility tracking
+
+```ts
+// directives/vIntersect.ts
+import type { Directive } from 'vue'
+
+interface IntersectBinding {
+  handler: (isIntersecting: boolean) => void
+  options?: IntersectionObserverInit
+}
+
+export const vIntersect: Directive<HTMLElement, IntersectBinding> = {
+  mounted(el, { value }) {
+    const observer = new IntersectionObserver(
+      ([entry]) => value.handler(entry.isIntersecting),
+      value.options
+    )
+    observer.observe(el)
+    el._intersectObserver = observer
+  },
+  unmounted(el) {
+    el._intersectObserver?.disconnect()
+  },
+}
+```
+
+### Registering directives globally
+
+```ts
+// main.ts
+import { createApp } from 'vue'
+import { vFocus } from '@/directives/vFocus'
+import { vClickOutside } from '@/directives/vClickOutside'
+
+const app = createApp(App)
+app.directive('focus', vFocus)
+app.directive('click-outside', vClickOutside)
+app.mount('#app')
+```
+
+### Directive lifecycle hooks reference
+
+```ts
+const myDirective: Directive = {
+  created(el, binding, vnode) {},       // before component attrs/events applied
+  beforeMount(el, binding, vnode) {},   // before element inserted into DOM
+  mounted(el, binding, vnode) {},       // after element inserted, children mounted
+  beforeUpdate(el, binding, vnode, prevVnode) {},  // before parent component updates
+  updated(el, binding, vnode, prevVnode) {},        // after parent and children updated
+  beforeUnmount(el, binding, vnode) {},  // before element removed
+  unmounted(el, binding, vnode) {},     // after element removed
+}
+
+// binding object shape:
+// binding.value   — value passed to directive (v-my-dir="value")
+// binding.oldValue — previous value (updated hook only)
+// binding.arg    — argument (v-my-dir:arg)
+// binding.modifiers — object { lazy: true } for v-my-dir.lazy
+// binding.instance — component instance
+```

+ 845 - 0
skills/vue-ops/references/nuxt.md

@@ -0,0 +1,845 @@
+# Nuxt 3 Reference
+
+Production patterns for Nuxt 3: rendering modes, data fetching, server routes, middleware, plugins, modules, SEO, deployment, and Nuxt Content.
+
+---
+
+## Architecture Overview
+
+Nuxt 3 is built on:
+- **Nitro** — universal server engine (runs on Node, Cloudflare Workers, Deno, Bun, etc.)
+- **Vite** — fast dev server and build tool
+- **Vue 3** — Composition API throughout
+- **Auto-imports** — no need to import `ref`, `computed`, `useFetch`, etc. — Nuxt imports them automatically
+- **File-based routing** — `pages/` directory maps to routes
+
+---
+
+## Rendering Modes
+
+### nuxt.config.ts — rendering configuration
+
+```ts
+// nuxt.config.ts
+export default defineNuxtConfig({
+  // SSR (default) — server renders each request
+  ssr: true,
+
+  // SPA mode — no server rendering
+  // ssr: false,
+
+  // Hybrid rendering — per-route rules (most powerful)
+  routeRules: {
+    '/': { prerender: true },                    // SSG — render at build time
+    '/blog': { prerender: true },
+    '/blog/**': { isr: 3600 },                   // ISR — regenerate every hour
+    '/shop/**': { swr: 600 },                    // SWR — stale-while-revalidate 10min
+    '/app/**': { ssr: true },                    // SSR — always server rendered
+    '/admin/**': { ssr: false },                 // SPA — client-only
+    '/api/**': { cors: true, headers: { 'cache-control': 's-maxage=0' } },
+  },
+})
+```
+
+### Prerendering specific routes
+
+```ts
+export default defineNuxtConfig({
+  nitro: {
+    prerender: {
+      routes: ['/', '/about', '/contact'],
+      crawlLinks: true,         // follow all <a> links and prerender them too
+      ignore: ['/admin'],
+    },
+  },
+})
+```
+
+---
+
+## Data Fetching
+
+### useFetch — SSR-safe primary fetching
+
+```vue
+<script setup lang="ts">
+interface Post { id: number; title: string; body: string }
+
+// Automatically de-duplicates on server/client, serializes for hydration
+const { data: post, pending, error, refresh } = await useFetch<Post>(
+  '/api/posts/1',
+  {
+    key: 'post-1',                      // deduplicate key (auto-generated if omitted)
+    server: true,                       // fetch on server (default)
+    lazy: false,                        // await before rendering (default)
+    default: () => ({ id: 0, title: '', body: '' } as Post),
+    transform: (data) => data,          // transform response before storing
+    pick: ['id', 'title'],              // pick only these fields (reduces payload)
+    watch: [userId],                    // re-fetch when these refs change
+  }
+)
+
+// Re-fetch manually
+async function reload() {
+  await refresh()
+}
+</script>
+```
+
+### useFetch with dynamic URL
+
+```vue
+<script setup lang="ts">
+const route = useRoute()
+
+// Reactive URL — re-fetches when route param changes
+const { data: user } = await useFetch(() => `/api/users/${route.params.id}`)
+</script>
+```
+
+### useAsyncData — custom async logic
+
+```vue
+<script setup lang="ts">
+// When you need more than a simple fetch (multiple sources, custom logic)
+const { data: stats } = await useAsyncData('dashboard-stats', async () => {
+  const [users, orders, revenue] = await Promise.all([
+    $fetch<User[]>('/api/users'),
+    $fetch<Order[]>('/api/orders'),
+    $fetch<number>('/api/revenue'),
+  ])
+  return { users, orders, revenue }
+})
+</script>
+```
+
+### $fetch — client-side / server-to-server fetching
+
+```ts
+// Use $fetch for:
+// - Actions triggered by user interaction (form submit, button click)
+// - Server route handlers
+// - Inside useAsyncData when you need to compose data
+
+// In a component action (not in setup):
+async function submitForm(data: FormData) {
+  const result = await $fetch('/api/submit', {
+    method: 'POST',
+    body: data,
+  })
+}
+
+// With error handling
+try {
+  const user = await $fetch<User>('/api/user', {
+    headers: useRequestHeaders(['cookie']),  // forward cookies for auth
+  })
+} catch (error) {
+  if (error.statusCode === 401) {
+    await navigateTo('/login')
+  }
+}
+```
+
+### Lazy fetching — render immediately, load async
+
+```vue
+<script setup lang="ts">
+// lazy: true — don't block render, data loads async
+const { data: comments, pending } = useFetch('/api/comments', { lazy: true })
+</script>
+
+<template>
+  <div v-if="pending" class="skeleton">Loading comments...</div>
+  <CommentList v-else :comments="comments" />
+</template>
+```
+
+---
+
+## Server Routes
+
+```
+server/
+├── api/              # Accessible at /api/*
+│   ├── users/
+│   │   ├── index.get.ts     # GET  /api/users
+│   │   ├── index.post.ts    # POST /api/users
+│   │   └── [id].get.ts      # GET  /api/users/:id
+│   └── auth/
+│       ├── login.post.ts
+│       └── logout.post.ts
+├── routes/           # Accessible at any path
+│   └── sitemap.xml.get.ts   # GET /sitemap.xml
+└── middleware/       # Runs on every server request
+    └── auth.ts
+```
+
+### Basic API route
+
+```ts
+// server/api/users/index.get.ts
+import { defineEventHandler, getQuery, H3Event } from 'h3'
+
+export default defineEventHandler(async (event: H3Event) => {
+  const query = getQuery(event)
+  const page = Number(query.page ?? 1)
+  const limit = Number(query.limit ?? 20)
+
+  const users = await db.users.findMany({
+    skip: (page - 1) * limit,
+    take: limit,
+  })
+
+  return users // automatically serialized as JSON
+})
+```
+
+### POST with validation (zod)
+
+```ts
+// server/api/users/index.post.ts
+import { defineEventHandler, readBody } from 'h3'
+import { z } from 'zod'
+
+const CreateUserSchema = z.object({
+  name: z.string().min(2).max(100),
+  email: z.string().email(),
+  role: z.enum(['user', 'admin']).default('user'),
+})
+
+export default defineEventHandler(async (event) => {
+  const body = await readBody(event)
+
+  // Validate — throws H3Error 400 on failure
+  const data = await CreateUserSchema.parseAsync(body).catch(() => {
+    throw createError({ statusCode: 400, statusMessage: 'Invalid request body' })
+  })
+
+  const user = await db.users.create({ data })
+  setResponseStatus(event, 201)
+  return user
+})
+```
+
+### Dynamic route parameter
+
+```ts
+// server/api/users/[id].get.ts
+import { defineEventHandler, getRouterParam } from 'h3'
+
+export default defineEventHandler(async (event) => {
+  const id = getRouterParam(event, 'id')
+
+  if (!id) throw createError({ statusCode: 400, statusMessage: 'ID required' })
+
+  const user = await db.users.findUnique({ where: { id: Number(id) } })
+
+  if (!user) throw createError({ statusCode: 404, statusMessage: 'User not found' })
+
+  return user
+})
+```
+
+### Server middleware — authentication
+
+```ts
+// server/middleware/auth.ts
+import { defineEventHandler, getCookie, createError } from 'h3'
+
+export default defineEventHandler(async (event) => {
+  // Only run auth check on /api/protected/* routes
+  if (!event.node.req.url?.startsWith('/api/protected')) return
+
+  const token = getCookie(event, 'auth_token')
+    ?? getHeader(event, 'authorization')?.replace('Bearer ', '')
+
+  if (!token) {
+    throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
+  }
+
+  const user = await verifyToken(token)
+  event.context.user = user  // attach to context for route handlers
+})
+```
+
+---
+
+## Nuxt Middleware
+
+### Route middleware (client-side navigation)
+
+```ts
+// middleware/auth.ts
+export default defineNuxtRouteMiddleware((to, from) => {
+  const auth = useAuthStore()
+
+  if (!auth.isLoggedIn) {
+    return navigateTo({
+      path: '/login',
+      query: { redirect: to.fullPath },
+    })
+  }
+})
+```
+
+### Using middleware in pages
+
+```vue
+<script setup lang="ts">
+// Named middleware — run auth.ts middleware
+definePageMeta({
+  middleware: ['auth'],
+  // Or inline:
+  // middleware: (to, from) => { ... }
+})
+</script>
+```
+
+### Global middleware (runs on every navigation)
+
+```ts
+// middleware/analytics.global.ts  ← '.global' suffix makes it run always
+export default defineNuxtRouteMiddleware((to) => {
+  if (import.meta.client) {
+    trackPageView(to.fullPath)
+  }
+})
+```
+
+### Server middleware (every HTTP request)
+
+```ts
+// server/middleware/logger.ts
+export default defineEventHandler((event) => {
+  console.log(`[${new Date().toISOString()}] ${event.node.req.method} ${event.node.req.url}`)
+})
+```
+
+---
+
+## Plugins
+
+### Client and server plugins
+
+```ts
+// plugins/my-plugin.ts — runs on both server and client
+export default defineNuxtPlugin((nuxtApp) => {
+  // Provide a helper to all components and composables
+  return {
+    provide: {
+      formatDate: (date: Date) => date.toLocaleDateString(),
+    },
+  }
+})
+```
+
+```ts
+// plugins/sentry.client.ts — client-only (filename convention)
+import * as Sentry from '@sentry/vue'
+
+export default defineNuxtPlugin((nuxtApp) => {
+  Sentry.init({
+    app: nuxtApp.vueApp,
+    dsn: useRuntimeConfig().public.sentryDsn,
+  })
+})
+```
+
+```ts
+// plugins/db.server.ts — server-only
+import { PrismaClient } from '@prisma/client'
+
+let prisma: PrismaClient
+
+export default defineNuxtPlugin(() => {
+  if (!prisma) prisma = new PrismaClient()
+  return { provide: { prisma } }
+})
+```
+
+### Accessing provided values
+
+```vue
+<script setup lang="ts">
+const { $formatDate, $prisma } = useNuxtApp()
+</script>
+```
+
+---
+
+## Modules
+
+### Using published modules
+
+```ts
+// nuxt.config.ts
+export default defineNuxtConfig({
+  modules: [
+    '@nuxtjs/tailwindcss',
+    '@pinia/nuxt',
+    '@nuxt/content',
+    '@nuxt/image',
+    '@nuxtjs/i18n',
+    'nuxt-icon',
+  ],
+
+  // Module configuration
+  pinia: {
+    autoImports: ['defineStore', 'storeToRefs'],
+  },
+})
+```
+
+### Building a custom module
+
+```ts
+// modules/feature-flags/index.ts
+import { defineNuxtModule, addPlugin, addImports, createResolver } from '@nuxt/kit'
+
+interface ModuleOptions {
+  flags: Record<string, boolean>
+}
+
+export default defineNuxtModule<ModuleOptions>({
+  meta: {
+    name: 'feature-flags',
+    configKey: 'featureFlags',
+  },
+  defaults: {
+    flags: {},
+  },
+  setup(options, nuxt) {
+    const resolver = createResolver(import.meta.url)
+
+    // Add runtime config
+    nuxt.options.runtimeConfig.public.featureFlags = options.flags
+
+    // Add a plugin
+    addPlugin(resolver.resolve('./runtime/plugin'))
+
+    // Add auto-imports
+    addImports({
+      name: 'useFeatureFlag',
+      from: resolver.resolve('./runtime/composables'),
+    })
+
+    // Hook into build process
+    nuxt.hook('build:before', () => {
+      console.log('Feature flags module: build starting')
+    })
+  },
+})
+```
+
+---
+
+## State Management in Nuxt
+
+### useState — SSR-safe shared state
+
+```ts
+// composables/useSharedState.ts
+// useState() is SSR-safe: same key = same state across components in same request
+export const useTheme = () => useState<'light' | 'dark'>('theme', () => 'light')
+export const useUser = () => useState<User | null>('user', () => null)
+```
+
+```vue
+<script setup lang="ts">
+const theme = useTheme()
+// Reactive and synced — changing in one component updates all others
+</script>
+```
+
+### Pinia with Nuxt (recommended for complex state)
+
+```ts
+// nuxt.config.ts
+export default defineNuxtConfig({
+  modules: ['@pinia/nuxt'],
+  pinia: { autoImports: ['defineStore', 'storeToRefs'] },
+})
+```
+
+```ts
+// stores/user.ts — works in Nuxt with SSR hydration
+export const useUserStore = defineStore('user', () => {
+  const user = ref<User | null>(null)
+
+  // In Nuxt: fetch on server, hydrate on client
+  async function fetchUser() {
+    user.value = await $fetch<User>('/api/user')
+  }
+
+  return { user, fetchUser }
+})
+```
+
+---
+
+## Runtime Config & Environment Variables
+
+```ts
+// nuxt.config.ts
+export default defineNuxtConfig({
+  runtimeConfig: {
+    // Private — only available on server (server routes, server-only plugins)
+    databaseUrl: process.env.DATABASE_URL,
+    jwtSecret: process.env.JWT_SECRET,
+
+    // Public — exposed to client via useRuntimeConfig().public
+    public: {
+      apiBase: process.env.NUXT_PUBLIC_API_BASE ?? '/api',
+      sentryDsn: process.env.NUXT_PUBLIC_SENTRY_DSN,
+      appVersion: process.env.npm_package_version,
+    },
+  },
+})
+```
+
+```ts
+// app.config.ts — UI configuration (not secrets, bundled into client)
+export default defineAppConfig({
+  ui: {
+    primary: 'blue',
+    notifications: { position: 'top-right' },
+  },
+})
+```
+
+```vue
+<script setup lang="ts">
+// Client and server: public config
+const config = useRuntimeConfig()
+const apiBase = config.public.apiBase
+
+// App config
+const appConfig = useAppConfig()
+const primaryColor = appConfig.ui.primary
+</script>
+```
+
+---
+
+## SEO
+
+### useHead and useSeoMeta
+
+```vue
+<script setup lang="ts">
+// useHead — full control
+useHead({
+  title: 'My Page',
+  titleTemplate: '%s — My Site',
+  meta: [
+    { name: 'description', content: 'Page description' },
+    { property: 'og:type', content: 'website' },
+  ],
+  link: [
+    { rel: 'canonical', href: 'https://mysite.com/page' },
+  ],
+  bodyAttrs: { class: 'dark-mode' },
+})
+
+// useSeoMeta — typed, tree-shakeable (preferred for meta tags)
+useSeoMeta({
+  title: 'My Page',
+  ogTitle: 'My Page',
+  description: 'Page description for SEO',
+  ogDescription: 'Page description for social sharing',
+  ogImage: 'https://mysite.com/og-image.png',
+  twitterCard: 'summary_large_image',
+})
+</script>
+```
+
+### defineOgImage — dynamic OG images
+
+```vue
+<script setup lang="ts">
+// @nuxtjs/og-image module
+defineOgImage({
+  component: 'MyOgImageTemplate',
+  props: { title: 'My Page', description: 'Description' },
+})
+</script>
+```
+
+### Dynamic head in layouts
+
+```vue
+<!-- layouts/default.vue -->
+<script setup lang="ts">
+useHead({
+  titleTemplate: (title) => title ? `${title} — My App` : 'My App',
+  htmlAttrs: { lang: 'en' },
+  link: [
+    { rel: 'icon', href: '/favicon.ico' },
+  ],
+})
+</script>
+```
+
+---
+
+## Error Handling
+
+### Error page (error.vue)
+
+```vue
+<!-- error.vue — root level, replaces app.vue on error -->
+<script setup lang="ts">
+const props = defineProps<{
+  error: {
+    statusCode: number
+    statusMessage: string
+    message: string
+  }
+}>()
+
+function handleError() {
+  clearError({ redirect: '/' })
+}
+</script>
+
+<template>
+  <div>
+    <h1>{{ error.statusCode }}</h1>
+    <p>{{ error.statusMessage }}</p>
+    <button @click="handleError">Go Home</button>
+  </div>
+</template>
+```
+
+### NuxtErrorBoundary — catch errors in subtree
+
+```vue
+<template>
+  <NuxtErrorBoundary @error="onError">
+    <AsyncComponent />
+    <template #error="{ error, clearError }">
+      <div>
+        <p>Something went wrong: {{ error.message }}</p>
+        <button @click="clearError()">Retry</button>
+      </div>
+    </template>
+  </NuxtErrorBoundary>
+</template>
+```
+
+### Throwing errors in server routes
+
+```ts
+// server/api/users/[id].get.ts
+export default defineEventHandler(async (event) => {
+  const user = await db.findUser(getRouterParam(event, 'id'))
+
+  if (!user) {
+    throw createError({
+      statusCode: 404,
+      statusMessage: 'User not found',
+      data: { id: getRouterParam(event, 'id') },
+    })
+  }
+
+  return user
+})
+```
+
+---
+
+## Deployment
+
+### Cloudflare Workers / Pages
+
+```ts
+// nuxt.config.ts
+export default defineNuxtConfig({
+  nitro: {
+    preset: 'cloudflare-pages', // or 'cloudflare'
+  },
+})
+```
+
+```toml
+# wrangler.toml (if using Workers)
+name = "my-nuxt-app"
+main = ".output/server/index.mjs"
+compatibility_date = "2024-01-01"
+compatibility_flags = ["nodejs_compat"]
+
+[[kv_namespaces]]
+binding = "KV"
+id = "your-kv-namespace-id"
+```
+
+### Vercel (auto-detected)
+
+```ts
+// nuxt.config.ts — Vercel detects automatically, no preset needed
+// But you can be explicit:
+export default defineNuxtConfig({
+  nitro: { preset: 'vercel' },
+})
+```
+
+### Node.js server
+
+```bash
+# Build
+npx nuxi build
+
+# Run
+node .output/server/index.mjs
+
+# With PM2
+pm2 start .output/server/index.mjs --name my-app
+```
+
+### Static hosting (full SSG)
+
+```bash
+# Generate static files
+npx nuxi generate
+
+# Output in .output/public/ — deploy to any static host
+```
+
+```ts
+// nuxt.config.ts for full static
+export default defineNuxtConfig({
+  ssr: true,
+  nitro: {
+    prerender: {
+      crawlLinks: true,
+      routes: ['/sitemap.xml'],
+    },
+  },
+})
+```
+
+---
+
+## Nuxt Content
+
+### Setup
+
+```bash
+npx nuxi module add content
+```
+
+```ts
+// nuxt.config.ts
+export default defineNuxtConfig({
+  modules: ['@nuxt/content'],
+  content: {
+    highlight: {
+      theme: 'github-dark',
+      langs: ['ts', 'vue', 'bash'],
+    },
+    markdown: {
+      anchorLinks: true,
+    },
+  },
+})
+```
+
+### Querying content
+
+```vue
+<!-- pages/blog/[slug].vue -->
+<script setup lang="ts">
+const route = useRoute()
+
+// Query a single document
+const { data: post } = await useAsyncData(
+  `blog-${route.params.slug}`,
+  () => queryContent('blog').where({ _path: `/blog/${route.params.slug}` }).findOne()
+)
+
+if (!post.value) throw createError({ statusCode: 404 })
+
+// SEO from frontmatter
+useSeoMeta({
+  title: post.value.title,
+  description: post.value.description,
+})
+</script>
+
+<template>
+  <!-- Renders markdown with MDC components -->
+  <ContentRenderer :value="post" />
+</template>
+```
+
+```vue
+<!-- Blog listing page -->
+<script setup lang="ts">
+const { data: posts } = await useAsyncData('blog-list', () =>
+  queryContent('blog')
+    .where({ published: true })
+    .sort({ date: -1 })
+    .only(['_path', 'title', 'description', 'date'])
+    .find()
+)
+</script>
+```
+
+### MDC — Markdown Components
+
+```md
+<!-- content/blog/my-post.md -->
+---
+title: My Post
+description: Post description
+date: 2024-01-15
+published: true
+---
+
+Regular markdown with **bold** and `code`.
+
+::alert{type="warning"}
+This renders the Alert.vue component from components/content/
+::
+
+:MyInlineComponent{prop="value"}
+```
+
+---
+
+## Performance Patterns
+
+### Component islands (selective hydration)
+
+```vue
+<!-- Heavy chart that only runs client-side -->
+<template>
+  <NuxtIsland name="HeavyChart" :props="{ data: chartData }" />
+</template>
+```
+
+### Payload optimization
+
+```vue
+<script setup lang="ts">
+// clearNuxtData removes payload after navigation (saves memory)
+onBeforeRouteLeave(() => {
+  clearNuxtData('heavy-data-key')
+})
+</script>
+```
+
+### Client-only components
+
+```vue
+<template>
+  <!-- Only renders on client — no SSR attempt -->
+  <ClientOnly>
+    <MapComponent />
+    <template #fallback>
+      <div class="map-skeleton" />
+    </template>
+  </ClientOnly>
+</template>
+```

+ 718 - 0
skills/vue-ops/references/state-routing.md

@@ -0,0 +1,718 @@
+# State Management & Routing Reference
+
+Advanced Pinia patterns and Vue Router configuration with TypeScript.
+
+---
+
+## Pinia — Setup Syntax (Recommended)
+
+The setup syntax mirrors `<script setup>` and is the preferred approach — full TypeScript inference, composables allowed inside, no `this` binding.
+
+```ts
+// stores/auth.ts
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import type { User } from '@/types'
+
+export const useAuthStore = defineStore('auth', () => {
+  // --- state (refs) ---
+  const user = ref<User | null>(null)
+  const token = ref<string | null>(null)
+  const loading = ref(false)
+
+  // --- getters (computed) ---
+  const isLoggedIn = computed(() => !!token.value)
+  const isAdmin = computed(() => user.value?.role === 'admin')
+  const displayName = computed(() => user.value?.name ?? 'Guest')
+
+  // --- actions (functions) ---
+  async function login(email: string, password: string) {
+    loading.value = true
+    try {
+      const res = await $fetch<{ user: User; token: string }>('/api/auth/login', {
+        method: 'POST',
+        body: { email, password },
+      })
+      user.value = res.user
+      token.value = res.token
+    } finally {
+      loading.value = false
+    }
+  }
+
+  function logout() {
+    user.value = null
+    token.value = null
+  }
+
+  return { user, token, loading, isLoggedIn, isAdmin, displayName, login, logout }
+})
+```
+
+---
+
+## Pinia — Options Syntax
+
+```ts
+// stores/cart.ts
+import { defineStore } from 'pinia'
+import type { CartItem, Product } from '@/types'
+
+export const useCartStore = defineStore('cart', {
+  state: () => ({
+    items: [] as CartItem[],
+    discount: 0,
+  }),
+
+  getters: {
+    // Getter with argument — return a function
+    itemById: (state) => (id: string) =>
+      state.items.find((item) => item.id === id),
+
+    total: (state): number =>
+      state.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
+
+    discountedTotal(): number {
+      // Can reference other getters via this
+      return this.total * (1 - this.discount)
+    },
+  },
+
+  actions: {
+    addItem(product: Product) {
+      const existing = this.itemById(product.id)
+      if (existing) {
+        existing.quantity++
+      } else {
+        this.items.push({ ...product, quantity: 1 })
+      }
+    },
+
+    removeItem(id: string) {
+      this.items = this.items.filter((item) => item.id !== id)
+    },
+
+    clearCart() {
+      // $reset() is available in options syntax to reset to initial state
+      this.$reset()
+    },
+  },
+})
+```
+
+---
+
+## Store Composition — Using One Store Inside Another
+
+```ts
+// stores/orders.ts
+import { defineStore } from 'pinia'
+import { computed } from 'vue'
+import { useAuthStore } from './auth'
+
+export const useOrdersStore = defineStore('orders', () => {
+  const auth = useAuthStore()
+
+  // Reactive dependency on auth store state
+  const userOrders = computed(() =>
+    allOrders.value.filter((o) => o.userId === auth.user?.id)
+  )
+
+  // Cross-store action
+  async function placeOrder(items: CartItem[]) {
+    if (!auth.isLoggedIn) throw new Error('Must be logged in')
+    return await $fetch('/api/orders', {
+      method: 'POST',
+      body: { userId: auth.user!.id, items },
+    })
+  }
+
+  return { userOrders, placeOrder }
+})
+```
+
+---
+
+## storeToRefs — Destructuring Without Losing Reactivity
+
+```vue
+<script setup lang="ts">
+import { storeToRefs } from 'pinia'
+import { useAuthStore } from '@/stores/auth'
+
+const auth = useAuthStore()
+
+// storeToRefs wraps state/getters in refs — safe to destructure
+const { user, isLoggedIn, displayName } = storeToRefs(auth)
+
+// Actions are plain functions — destructure directly from store
+const { login, logout } = auth
+
+// BAD — loses reactivity:
+// const { user } = auth   // user is now a plain value, not reactive
+</script>
+```
+
+---
+
+## Pinia Plugins
+
+### Persistence plugin (pinia-plugin-persistedstate)
+
+```ts
+// main.ts
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
+import App from './App.vue'
+
+const pinia = createPinia()
+pinia.use(piniaPluginPersistedstate)
+
+createApp(App).use(pinia).mount('#app')
+```
+
+```ts
+// Store with selective persistence
+export const usePreferencesStore = defineStore('preferences', () => {
+  const theme = ref<'light' | 'dark'>('light')
+  const language = ref('en')
+  const notifications = ref(true)
+
+  return { theme, language, notifications }
+}, {
+  persist: {
+    paths: ['theme', 'language'],     // only persist these
+    storage: localStorage,
+    serializer: {
+      deserialize: JSON.parse,
+      serialize: JSON.stringify,
+    },
+  },
+})
+```
+
+### Custom plugin — logging
+
+```ts
+// plugins/pinia-logger.ts
+import type { PiniaPluginContext } from 'pinia'
+
+export function PiniaLogger({ store }: PiniaPluginContext) {
+  store.$onAction(({ name, args, after, onError }) => {
+    console.group(`[Pinia] ${store.$id}.${name}`)
+    console.log('args:', args)
+
+    after((result) => {
+      console.log('result:', result)
+      console.groupEnd()
+    })
+
+    onError((error) => {
+      console.error('error:', error)
+      console.groupEnd()
+    })
+  })
+}
+```
+
+### Custom plugin — undo/redo
+
+```ts
+// plugins/pinia-history.ts
+import { ref } from 'vue'
+import type { PiniaPluginContext } from 'pinia'
+
+export function PiniaHistory({ store }: PiniaPluginContext) {
+  const history: string[] = []
+  let historyIndex = -1
+
+  // Snapshot state on every change
+  store.$subscribe((mutation, state) => {
+    // Drop future history on new action
+    history.splice(historyIndex + 1)
+    history.push(JSON.stringify(state))
+    historyIndex = history.length - 1
+  })
+
+  store.undo = () => {
+    if (historyIndex > 0) {
+      historyIndex--
+      store.$patch(JSON.parse(history[historyIndex]))
+    }
+  }
+
+  store.redo = () => {
+    if (historyIndex < history.length - 1) {
+      historyIndex++
+      store.$patch(JSON.parse(history[historyIndex]))
+    }
+  }
+}
+```
+
+---
+
+## Pinia SSR — State Hydration
+
+```ts
+// Nuxt: state is automatically serialized and hydrated via useNuxtApp().$pinia
+// For custom SSR with Vite/Express:
+
+// server.ts
+import { createPinia } from 'pinia'
+
+export async function render(url: string) {
+  const pinia = createPinia()
+  const app = createApp(App)
+  app.use(pinia)
+
+  await renderToString(app)
+
+  // Serialize state to embed in HTML
+  const state = JSON.stringify(pinia.state.value)
+  return { state }
+}
+
+// client.ts
+import { createPinia } from 'pinia'
+
+const pinia = createPinia()
+
+// Hydrate from server-serialized state
+if (window.__INITIAL_STATE__) {
+  pinia.state.value = JSON.parse(
+    decodeURIComponent(atob(window.__INITIAL_STATE__))
+  )
+}
+
+createApp(App).use(pinia).mount('#app')
+```
+
+---
+
+## Pinia Store Subscriptions
+
+```ts
+const store = useCartStore()
+
+// Subscribe to state changes
+const unsubscribe = store.$subscribe((mutation, state) => {
+  // mutation.type: 'direct' | 'patch object' | 'patch function'
+  // mutation.storeId: store id
+  // mutation.payload: patch object (if type is 'patch object')
+  console.log('state changed', state)
+})
+
+// Subscribe to actions
+store.$onAction(({ name, store, args, after, onError }) => {
+  after((result) => { /* action succeeded */ })
+  onError((error) => { /* action threw */ })
+})
+
+// Cleanup
+onUnmounted(unsubscribe)
+```
+
+---
+
+## Vue Router — Full Configuration
+
+```ts
+// router/index.ts
+import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'
+import type { RouteRecordRaw } from 'vue-router'
+
+// TypeScript meta augmentation
+declare module 'vue-router' {
+  interface RouteMeta {
+    requiresAuth?: boolean
+    roles?: string[]
+    title?: string
+    breadcrumb?: string
+    transition?: string
+    keepAlive?: boolean
+  }
+}
+
+const routes: RouteRecordRaw[] = [
+  {
+    path: '/',
+    name: 'home',
+    component: () => import('@/views/HomeView.vue'),
+    meta: { title: 'Home' },
+  },
+  {
+    path: '/about',
+    name: 'about',
+    // Route-level code splitting — this route is lazy loaded
+    component: () => import('@/views/AboutView.vue'),
+  },
+  {
+    path: '/users/:id(\\d+)',      // only match numeric ids
+    name: 'user',
+    component: () => import('@/views/UserView.vue'),
+    props: true,                    // route params passed as props
+    meta: { requiresAuth: true, title: 'User Profile' },
+  },
+  {
+    path: '/users/:id/settings',
+    name: 'user-settings',
+    component: () => import('@/views/UserSettingsView.vue'),
+    props: (route) => ({ id: Number(route.params.id) }), // transform params
+  },
+  {
+    path: '/blog/:slug?',           // optional param
+    name: 'blog-post',
+    component: () => import('@/views/BlogView.vue'),
+  },
+  {
+    path: '/admin',
+    redirect: '/admin/dashboard',
+    component: () => import('@/layouts/AdminLayout.vue'),
+    meta: { requiresAuth: true, roles: ['admin'] },
+    children: [
+      {
+        path: 'dashboard',
+        name: 'admin-dashboard',
+        component: () => import('@/views/admin/DashboardView.vue'),
+      },
+      {
+        path: 'users',
+        name: 'admin-users',
+        component: () => import('@/views/admin/UsersView.vue'),
+        alias: '/users-admin',       // accessible at both paths
+      },
+    ],
+  },
+  {
+    path: '/:pathMatch(.*)*',
+    name: 'not-found',
+    component: () => import('@/views/NotFoundView.vue'),
+  },
+]
+
+export const router = createRouter({
+  history: createWebHistory(import.meta.env.BASE_URL),
+  // history: createWebHashHistory() — for hash-based routing (#/path)
+  routes,
+  scrollBehavior(to, from, savedPosition) {
+    if (savedPosition) {
+      // Restore scroll position when using browser back/forward
+      return savedPosition
+    }
+    if (to.hash) {
+      return { el: to.hash, behavior: 'smooth', top: 80 }
+    }
+    // Scroll to top on navigation, but only if path changed
+    if (to.path !== from.path) {
+      return { top: 0 }
+    }
+  },
+})
+```
+
+---
+
+## Navigation Guards
+
+### Global guards — auth and title
+
+```ts
+// router/guards.ts
+import { router } from './index'
+import { useAuthStore } from '@/stores/auth'
+
+router.beforeEach(async (to, from) => {
+  // Set page title
+  document.title = to.meta.title ? `${to.meta.title} — MyApp` : 'MyApp'
+
+  const auth = useAuthStore()
+
+  // Wait for auth to initialize (e.g., token check from localStorage)
+  if (!auth.initialized) {
+    await auth.initialize()
+  }
+
+  // Auth guard
+  if (to.meta.requiresAuth && !auth.isLoggedIn) {
+    return {
+      name: 'login',
+      query: { redirect: to.fullPath },
+    }
+  }
+
+  // Role guard
+  if (to.meta.roles?.length && !to.meta.roles.includes(auth.user?.role ?? '')) {
+    return { name: 'forbidden' }
+  }
+})
+
+router.afterEach((to, from, failure) => {
+  if (!failure) {
+    // Analytics, etc.
+    trackPageView(to.fullPath)
+  }
+})
+```
+
+### Per-route beforeEnter guard
+
+```ts
+{
+  path: '/checkout',
+  name: 'checkout',
+  component: () => import('@/views/CheckoutView.vue'),
+  beforeEnter: [
+    // Multiple guards as array — executed in order
+    requireAuth,
+    requireNonEmptyCart,
+  ],
+}
+
+function requireAuth(to, from) {
+  const auth = useAuthStore()
+  if (!auth.isLoggedIn) return { name: 'login', query: { redirect: to.fullPath } }
+}
+
+function requireNonEmptyCart(to, from) {
+  const cart = useCartStore()
+  if (cart.items.length === 0) return { name: 'cart' }
+}
+```
+
+### In-component guards (Composition API)
+
+```vue
+<script setup lang="ts">
+import {
+  onBeforeRouteLeave,
+  onBeforeRouteUpdate,
+  useRoute,
+  useRouter,
+} from 'vue-router'
+import { ref, watch } from 'vue'
+
+const route = useRoute()
+const router = useRouter()
+const isDirty = ref(false)
+
+// Guard: prevent navigating away with unsaved changes
+onBeforeRouteLeave((to, from) => {
+  if (isDirty.value) {
+    const confirmed = window.confirm('Leave without saving?')
+    if (!confirmed) return false
+  }
+})
+
+// Guard: refetch data when param changes (e.g., /users/1 → /users/2)
+onBeforeRouteUpdate(async (to, from) => {
+  if (to.params.id !== from.params.id) {
+    await fetchUser(to.params.id as string)
+  }
+})
+
+// Alternative: watch route params reactively
+watch(() => route.params.id, async (newId) => {
+  if (newId) await fetchUser(newId as string)
+}, { immediate: true })
+</script>
+```
+
+---
+
+## Dynamic Routes
+
+```ts
+// Programmatic navigation
+const router = useRouter()
+
+// Navigate to named route
+router.push({ name: 'user', params: { id: 42 } })
+
+// Navigate with query params
+router.push({ name: 'search', query: { q: 'vue', page: 2 } })
+
+// Replace current history entry (no back button)
+router.replace({ name: 'login' })
+
+// Navigate back/forward
+router.go(-1)
+router.back()
+router.forward()
+```
+
+```ts
+// Adding routes dynamically (e.g., from plugin or feature flag)
+const removeRoute = router.addRoute({
+  path: '/feature-x',
+  name: 'feature-x',
+  component: () => import('@/views/FeatureX.vue'),
+})
+
+// Remove the route when feature is disabled
+removeRoute()
+```
+
+### useRoute — accessing route state
+
+```vue
+<script setup lang="ts">
+import { useRoute } from 'vue-router'
+import { computed } from 'vue'
+
+const route = useRoute()
+
+// Params — always strings or arrays of strings
+const userId = computed(() => Number(route.params.id))
+
+// Query params
+const search = computed(() => route.query.q as string ?? '')
+const page = computed(() => Number(route.query.page ?? 1))
+
+// Route meta
+const pageTitle = computed(() => route.meta.title)
+
+// Full path and matched routes (breadcrumb data)
+const breadcrumbs = computed(() =>
+  route.matched.map((r) => ({ name: r.name, label: r.meta.breadcrumb }))
+)
+</script>
+```
+
+---
+
+## Route Transitions
+
+### Per-route transition names
+
+```vue
+<!-- App.vue -->
+<script setup lang="ts">
+import { useRoute } from 'vue-router'
+const route = useRoute()
+</script>
+
+<template>
+  <RouterView v-slot="{ Component, route }">
+    <Transition :name="route.meta.transition ?? 'fade'" mode="out-in">
+      <component :is="Component" :key="route.path" />
+    </Transition>
+  </RouterView>
+</template>
+
+<style>
+.fade-enter-active,
+.fade-leave-active { transition: opacity 0.2s ease; }
+.fade-enter-from,
+.fade-leave-to { opacity: 0; }
+
+.slide-enter-active,
+.slide-leave-active { transition: transform 0.3s ease; }
+.slide-enter-from { transform: translateX(100%); }
+.slide-leave-to { transform: translateX(-100%); }
+</style>
+```
+
+```ts
+// Route definition with transition
+{
+  path: '/users',
+  component: () => import('@/views/UsersView.vue'),
+  meta: { transition: 'slide' },
+}
+```
+
+### View Transitions API (Chrome 111+)
+
+```ts
+router.beforeEach(() => {
+  if (!document.startViewTransition) return
+
+  return new Promise((resolve) => {
+    document.startViewTransition(resolve)
+  })
+})
+```
+
+---
+
+## Lazy Loading
+
+### Route-level code splitting
+
+```ts
+// Each () => import() creates a separate chunk
+const routes = [
+  { path: '/dashboard', component: () => import('@/views/Dashboard.vue') },
+  { path: '/settings', component: () => import('@/views/Settings.vue') },
+]
+```
+
+### defineAsyncComponent with loading and error states
+
+```ts
+import { defineAsyncComponent } from 'vue'
+import Spinner from '@/components/Spinner.vue'
+import ErrorDisplay from '@/components/ErrorDisplay.vue'
+
+const AsyncHeavyChart = defineAsyncComponent({
+  loader: () => import('@/components/HeavyChart.vue'),
+  loadingComponent: Spinner,
+  errorComponent: ErrorDisplay,
+  delay: 200,           // show loading after 200ms (avoids flash)
+  timeout: 10000,       // error if not loaded within 10s
+  onError(error, retry, fail, attempts) {
+    if (attempts <= 3) retry()  // retry up to 3 times
+    else fail()
+  },
+})
+```
+
+### Grouping chunks with magic comments
+
+```ts
+// Vite: chunks are auto-split, but you can group with same chunk name
+const UserProfile = () => import(/* @vite-ignore */ '@/views/UserProfile.vue')
+
+// Prefetch on hover (manual)
+function prefetchDashboard() {
+  import('@/views/Dashboard.vue')
+}
+```
+
+---
+
+## Scroll Behavior Patterns
+
+```ts
+scrollBehavior(to, from, savedPosition) {
+  // 1. Browser back/forward → restore exact position
+  if (savedPosition) return savedPosition
+
+  // 2. Hash link → scroll to element
+  if (to.hash) {
+    return {
+      el: to.hash,
+      top: 80,              // offset for sticky header
+      behavior: 'smooth',
+    }
+  }
+
+  // 3. New page → scroll to top
+  return { top: 0, left: 0 }
+}
+```
+
+### Async scroll (wait for transition)
+
+```ts
+scrollBehavior(to, from, savedPosition) {
+  return new Promise((resolve) => {
+    // Wait for page transition to complete
+    setTimeout(() => {
+      resolve(savedPosition ?? { top: 0 })
+    }, 300)
+  })
+}
+```

+ 847 - 0
skills/vue-ops/references/testing.md

@@ -0,0 +1,847 @@
+# Testing Reference
+
+Vue 3 testing with Vitest, Vue Test Utils, Pinia, Vue Router, MSW, Playwright, and Nuxt test utils.
+
+---
+
+## Vitest Setup for Vue
+
+### Installation
+
+```bash
+npm install -D vitest @vue/test-utils happy-dom @vitest/coverage-v8
+# Or jsdom:
+npm install -D jsdom
+```
+
+### vitest.config.ts
+
+```ts
+import { defineConfig } from 'vitest/config'
+import vue from '@vitejs/plugin-vue'
+import { fileURLToPath } from 'node:url'
+
+export default defineConfig({
+  plugins: [vue()],
+  test: {
+    environment: 'happy-dom',  // or 'jsdom'
+    globals: true,             // describe/it/expect without importing
+    setupFiles: ['./tests/setup.ts'],
+    coverage: {
+      provider: 'v8',
+      reporter: ['text', 'lcov', 'html'],
+      thresholds: {
+        lines: 80,
+        branches: 75,
+        functions: 80,
+      },
+      exclude: ['**/node_modules/**', '**/dist/**', '**/*.d.ts'],
+    },
+  },
+  resolve: {
+    alias: {
+      '@': fileURLToPath(new URL('./src', import.meta.url)),
+    },
+  },
+})
+```
+
+### tests/setup.ts — global test setup
+
+```ts
+import { config } from '@vue/test-utils'
+import { createTestingPinia } from '@pinia/testing'
+
+// Global component stubs
+config.global.stubs = {
+  RouterLink: true,
+  RouterView: true,
+  Teleport: true,
+}
+
+// Suppress Vue warnings in tests (optional — often better to fix them)
+// config.global.config.warnHandler = () => null
+```
+
+### Auto-imports with unplugin-auto-import
+
+```ts
+// vitest.config.ts — if using auto-imports in app
+import AutoImport from 'unplugin-auto-import/vite'
+
+export default defineConfig({
+  plugins: [
+    vue(),
+    AutoImport({
+      imports: ['vue', 'vue-router', 'pinia'],
+      dts: true,
+    }),
+  ],
+  test: { globals: true },
+})
+```
+
+---
+
+## Vue Test Utils — Mounting
+
+### mount vs shallowMount
+
+```ts
+import { mount, shallowMount } from '@vue/test-utils'
+import UserCard from '@/components/UserCard.vue'
+
+// mount — renders the full component tree (children included)
+const wrapper = mount(UserCard, {
+  props: { user: { id: 1, name: 'Alice' } },
+})
+
+// shallowMount — stubs child components (faster, more isolated)
+// WARNING: can hide integration bugs; prefer mount for most cases
+const wrapper = shallowMount(UserCard, {
+  props: { user: { id: 1, name: 'Alice' } },
+})
+```
+
+### Mounting options
+
+```ts
+const wrapper = mount(MyComponent, {
+  props: {
+    title: 'Hello',
+    items: [1, 2, 3],
+  },
+  slots: {
+    default: '<p>Default slot content</p>',
+    header: '<h2>Header slot</h2>',
+  },
+  global: {
+    plugins: [router, createTestingPinia()],
+    stubs: {
+      'FontAwesomeIcon': true,         // stub by name
+      ChildComponent: { template: '<div class="child-stub" />' },
+    },
+    mocks: {
+      $t: (key: string) => key,        // mock i18n
+    },
+    provide: {
+      theme: ref('dark'),
+    },
+  },
+  attachTo: document.body,             // needed for focus tests
+})
+```
+
+---
+
+## Component Testing Patterns
+
+### Rendering and querying the DOM
+
+```ts
+import { mount } from '@vue/test-utils'
+import { describe, it, expect } from 'vitest'
+import UserList from '@/components/UserList.vue'
+
+const users = [
+  { id: 1, name: 'Alice', role: 'admin' },
+  { id: 2, name: 'Bob', role: 'user' },
+]
+
+describe('UserList', () => {
+  it('renders a list of users', () => {
+    const wrapper = mount(UserList, { props: { users } })
+
+    // Text content
+    expect(wrapper.text()).toContain('Alice')
+
+    // Element exists
+    expect(wrapper.find('[data-testid="user-list"]').exists()).toBe(true)
+
+    // Count elements
+    expect(wrapper.findAll('.user-card')).toHaveLength(2)
+
+    // Check attribute
+    expect(wrapper.find('input').attributes('disabled')).toBeDefined()
+
+    // Check CSS class
+    expect(wrapper.find('.user-card').classes()).toContain('admin')
+  })
+
+  it('renders empty state when no users', () => {
+    const wrapper = mount(UserList, { props: { users: [] } })
+    expect(wrapper.find('[data-testid="empty-state"]').exists()).toBe(true)
+  })
+})
+```
+
+### User interactions
+
+```ts
+import { mount, flushPromises } from '@vue/test-utils'
+import SearchInput from '@/components/SearchInput.vue'
+import { nextTick } from 'vue'
+
+describe('SearchInput', () => {
+  it('emits search event when user types and submits', async () => {
+    const wrapper = mount(SearchInput)
+
+    // Fill input
+    await wrapper.find('input').setValue('vue testing')
+
+    // Click button
+    await wrapper.find('button[type="submit"]').trigger('click')
+
+    // Check emitted events
+    expect(wrapper.emitted('search')).toBeTruthy()
+    expect(wrapper.emitted('search')![0]).toEqual(['vue testing'])
+  })
+
+  it('clears input on escape key', async () => {
+    const wrapper = mount(SearchInput)
+    await wrapper.find('input').setValue('hello')
+    await wrapper.find('input').trigger('keydown', { key: 'Escape' })
+
+    expect((wrapper.find('input').element as HTMLInputElement).value).toBe('')
+  })
+})
+```
+
+### Async behavior
+
+```ts
+import { mount, flushPromises } from '@vue/test-utils'
+import { vi } from 'vitest'
+import PostList from '@/components/PostList.vue'
+
+describe('PostList', () => {
+  it('shows loading then content after fetch', async () => {
+    // Mock fetch
+    vi.spyOn(global, 'fetch').mockResolvedValueOnce({
+      ok: true,
+      json: async () => [{ id: 1, title: 'Post One' }],
+    } as Response)
+
+    const wrapper = mount(PostList)
+
+    // Initially shows loading
+    expect(wrapper.find('[data-testid="loading"]').exists()).toBe(true)
+
+    // Wait for all promises to resolve
+    await flushPromises()
+
+    // Now shows content
+    expect(wrapper.find('[data-testid="loading"]').exists()).toBe(false)
+    expect(wrapper.text()).toContain('Post One')
+  })
+})
+```
+
+### Slot testing
+
+```ts
+import { mount } from '@vue/test-utils'
+import Card from '@/components/Card.vue'
+
+describe('Card slots', () => {
+  it('renders named slots', () => {
+    const wrapper = mount(Card, {
+      slots: {
+        header: '<h2 data-testid="card-header">My Title</h2>',
+        default: '<p>Card body</p>',
+        footer: '<button>Action</button>',
+      },
+    })
+
+    expect(wrapper.find('[data-testid="card-header"]').text()).toBe('My Title')
+    expect(wrapper.find('p').text()).toBe('Card body')
+  })
+
+  it('renders scoped slot with data', () => {
+    const wrapper = mount(DataTable, {
+      props: { items: [{ id: 1, name: 'Alice' }] },
+      slots: {
+        row: `<template #row="{ item }">
+          <span data-testid="row-name">{{ item.name }}</span>
+        </template>`,
+      },
+    })
+
+    expect(wrapper.find('[data-testid="row-name"]').text()).toBe('Alice')
+  })
+})
+```
+
+---
+
+## Testing Composables
+
+### Simple composable test
+
+```ts
+// tests/composables/useCounter.test.ts
+import { describe, it, expect } from 'vitest'
+import { useCounter } from '@/composables/useCounter'
+
+describe('useCounter', () => {
+  it('starts at initial value', () => {
+    const { count } = useCounter(5)
+    expect(count.value).toBe(5)
+  })
+
+  it('increments and decrements', () => {
+    const { count, increment, decrement } = useCounter(0)
+    increment()
+    increment()
+    expect(count.value).toBe(2)
+    decrement()
+    expect(count.value).toBe(1)
+  })
+
+  it('resets to initial value', () => {
+    const { count, increment, reset } = useCounter(10)
+    increment()
+    reset()
+    expect(count.value).toBe(10)
+  })
+})
+```
+
+### Composable requiring component context (lifecycle hooks)
+
+```ts
+// tests/composables/useEventListener.test.ts
+import { describe, it, expect, vi } from 'vitest'
+import { defineComponent, ref } from 'vue'
+import { mount } from '@vue/test-utils'
+import { useEventListener } from '@/composables/useEventListener'
+
+describe('useEventListener', () => {
+  it('adds and removes event listener with component lifecycle', async () => {
+    const handler = vi.fn()
+
+    // Wrap in a component to get lifecycle
+    const TestComponent = defineComponent({
+      setup() {
+        useEventListener(window, 'resize', handler)
+      },
+      template: '<div />',
+    })
+
+    const wrapper = mount(TestComponent)
+
+    // Trigger event
+    window.dispatchEvent(new Event('resize'))
+    expect(handler).toHaveBeenCalledTimes(1)
+
+    // Unmount — listener should be removed
+    wrapper.unmount()
+    window.dispatchEvent(new Event('resize'))
+    expect(handler).toHaveBeenCalledTimes(1) // still 1, not 2
+  })
+})
+```
+
+### Composable with mocked fetch
+
+```ts
+// tests/composables/useFetch.test.ts
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { defineComponent, ref } from 'vue'
+import { mount, flushPromises } from '@vue/test-utils'
+import { useFetch } from '@/composables/useFetch'
+
+const mockData = { id: 1, name: 'Alice' }
+
+describe('useFetch', () => {
+  beforeEach(() => {
+    vi.spyOn(global, 'fetch').mockResolvedValue({
+      ok: true,
+      json: async () => mockData,
+    } as Response)
+  })
+
+  it('fetches data and updates refs', async () => {
+    const TestComponent = defineComponent({
+      setup() {
+        const { data, pending, error } = useFetch<typeof mockData>('/api/user')
+        return { data, pending, error }
+      },
+      template: '<div />',
+    })
+
+    const wrapper = mount(TestComponent)
+    expect(wrapper.vm.pending).toBe(true)
+
+    await flushPromises()
+    expect(wrapper.vm.pending).toBe(false)
+    expect(wrapper.vm.data).toEqual(mockData)
+    expect(wrapper.vm.error).toBeNull()
+  })
+})
+```
+
+---
+
+## Pinia Testing
+
+### createTestingPinia — mock store
+
+```ts
+import { mount } from '@vue/test-utils'
+import { createTestingPinia } from '@pinia/testing'
+import { vi } from 'vitest'
+import UserProfile from '@/components/UserProfile.vue'
+import { useUserStore } from '@/stores/user'
+
+describe('UserProfile', () => {
+  it('displays user name from store', () => {
+    const wrapper = mount(UserProfile, {
+      global: {
+        plugins: [
+          createTestingPinia({
+            initialState: {
+              user: { currentUser: { id: 1, name: 'Alice', role: 'admin' } },
+            },
+          }),
+        ],
+      },
+    })
+
+    expect(wrapper.text()).toContain('Alice')
+  })
+
+  it('calls logout action when button clicked', async () => {
+    const wrapper = mount(UserProfile, {
+      global: {
+        plugins: [
+          createTestingPinia({
+            createSpy: vi.fn,          // makes all actions spies
+          }),
+        ],
+      },
+    })
+
+    const store = useUserStore()
+    await wrapper.find('[data-testid="logout-btn"]').trigger('click')
+    expect(store.logout).toHaveBeenCalledOnce()
+  })
+
+  it('can stub specific action', async () => {
+    const wrapper = mount(UserProfile, {
+      global: {
+        plugins: [
+          createTestingPinia({
+            createSpy: vi.fn,
+            stubActions: false,        // let real actions run
+          }),
+        ],
+      },
+    })
+
+    const store = useUserStore()
+    // Override specific action
+    store.logout = vi.fn().mockResolvedValue(undefined)
+  })
+})
+```
+
+### Testing store in isolation
+
+```ts
+import { setActivePinia, createPinia } from 'pinia'
+import { beforeEach, describe, it, expect, vi } from 'vitest'
+import { useCartStore } from '@/stores/cart'
+
+describe('useCartStore', () => {
+  beforeEach(() => {
+    // Create a fresh pinia before each test
+    setActivePinia(createPinia())
+  })
+
+  it('adds item to cart', () => {
+    const cart = useCartStore()
+    const product = { id: '1', name: 'Widget', price: 9.99 }
+
+    cart.addItem(product)
+    expect(cart.items).toHaveLength(1)
+    expect(cart.total).toBe(9.99)
+  })
+
+  it('increments quantity for duplicate item', () => {
+    const cart = useCartStore()
+    const product = { id: '1', name: 'Widget', price: 9.99 }
+
+    cart.addItem(product)
+    cart.addItem(product)
+
+    expect(cart.items).toHaveLength(1)
+    expect(cart.items[0].quantity).toBe(2)
+  })
+
+  it('calls API when placing order', async () => {
+    const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValueOnce({
+      ok: true,
+      json: async () => ({ orderId: 'abc123' }),
+    } as Response)
+
+    const cart = useCartStore()
+    await cart.checkout()
+
+    expect(fetchSpy).toHaveBeenCalledWith('/api/orders', expect.any(Object))
+  })
+})
+```
+
+---
+
+## Vue Router Testing
+
+### Router mock for navigation testing
+
+```ts
+import { mount, RouterLinkStub } from '@vue/test-utils'
+import { createRouter, createMemoryHistory } from 'vue-router'
+import NavBar from '@/components/NavBar.vue'
+
+describe('NavBar navigation', () => {
+  it('has correct links', () => {
+    const wrapper = mount(NavBar, {
+      global: {
+        stubs: { RouterLink: RouterLinkStub },
+      },
+    })
+
+    const links = wrapper.findAllComponents(RouterLinkStub)
+    expect(links.some((l) => l.props('to') === '/')).toBe(true)
+    expect(links.some((l) => l.props('to') === '/about')).toBe(true)
+  })
+
+  it('navigates on click', async () => {
+    const router = createRouter({
+      history: createMemoryHistory(),
+      routes: [
+        { path: '/', component: { template: '<div>Home</div>' } },
+        { path: '/about', component: { template: '<div>About</div>' } },
+      ],
+    })
+
+    const wrapper = mount(NavBar, {
+      global: { plugins: [router] },
+    })
+
+    await router.isReady()
+    await wrapper.find('[data-testid="about-link"]').trigger('click')
+    await router.isReady()
+
+    expect(router.currentRoute.value.path).toBe('/about')
+  })
+})
+```
+
+### Testing components that use useRoute/useRouter
+
+```ts
+import { mount } from '@vue/test-utils'
+import { createRouter, createMemoryHistory } from 'vue-router'
+import UserView from '@/views/UserView.vue'
+
+describe('UserView', () => {
+  it('loads user from route param', async () => {
+    const router = createRouter({
+      history: createMemoryHistory(),
+      routes: [{ path: '/users/:id', component: UserView }],
+    })
+
+    await router.push('/users/42')
+    await router.isReady()
+
+    const wrapper = mount(UserView, {
+      global: { plugins: [router] },
+    })
+
+    await flushPromises()
+    // UserView reads route.params.id = '42'
+    expect(wrapper.text()).toContain('User 42')
+  })
+})
+```
+
+---
+
+## API Mocking with MSW
+
+### Setup
+
+```bash
+npm install -D msw
+npx msw init public/
+```
+
+```ts
+// tests/mocks/handlers.ts
+import { http, HttpResponse } from 'msw'
+import type { User } from '@/types'
+
+export const handlers = [
+  http.get('/api/users', () => {
+    return HttpResponse.json<User[]>([
+      { id: 1, name: 'Alice', email: 'alice@example.com' },
+      { id: 2, name: 'Bob', email: 'bob@example.com' },
+    ])
+  }),
+
+  http.get('/api/users/:id', ({ params }) => {
+    const user = { id: Number(params.id), name: 'Alice', email: 'alice@example.com' }
+    return HttpResponse.json(user)
+  }),
+
+  http.post('/api/users', async ({ request }) => {
+    const body = await request.json() as Partial<User>
+    return HttpResponse.json({ ...body, id: 999 }, { status: 201 })
+  }),
+]
+```
+
+```ts
+// tests/setup.ts — global MSW setup
+import { setupServer } from 'msw/node'
+import { handlers } from './mocks/handlers'
+
+const server = setupServer(...handlers)
+
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
+afterEach(() => server.resetHandlers())
+afterAll(() => server.close())
+```
+
+```ts
+// Override handlers per test
+import { http, HttpResponse } from 'msw'
+
+it('shows error when API fails', async () => {
+  server.use(
+    http.get('/api/users', () => {
+      return HttpResponse.json({ message: 'Server Error' }, { status: 500 })
+    })
+  )
+  // ... test error state
+})
+```
+
+---
+
+## Snapshot Testing
+
+```ts
+import { mount } from '@vue/test-utils'
+import { describe, it, expect } from 'vitest'
+import Button from '@/components/Button.vue'
+
+describe('Button', () => {
+  it('matches snapshot', () => {
+    const wrapper = mount(Button, {
+      props: { variant: 'primary', size: 'md' },
+      slots: { default: 'Click me' },
+    })
+
+    // HTML snapshot
+    expect(wrapper.html()).toMatchSnapshot()
+  })
+
+  it('matches inline snapshot', () => {
+    const wrapper = mount(Button, {
+      props: { variant: 'danger' },
+      slots: { default: 'Delete' },
+    })
+
+    expect(wrapper.html()).toMatchInlineSnapshot(`
+      "<button class="btn btn-danger">Delete</button>"
+    `)
+  })
+})
+```
+
+---
+
+## E2E with Playwright
+
+### Setup
+
+```bash
+npm install -D @playwright/test
+npx playwright install
+```
+
+### Page Object pattern for Vue apps
+
+```ts
+// tests/e2e/pages/LoginPage.ts
+import { Page, Locator } from '@playwright/test'
+
+export class LoginPage {
+  readonly page: Page
+  readonly emailInput: Locator
+  readonly passwordInput: Locator
+  readonly submitButton: Locator
+  readonly errorMessage: Locator
+
+  constructor(page: Page) {
+    this.page = page
+    this.emailInput = page.getByLabel('Email')
+    this.passwordInput = page.getByLabel('Password')
+    this.submitButton = page.getByRole('button', { name: 'Sign in' })
+    this.errorMessage = page.getByTestId('login-error')
+  }
+
+  async goto() {
+    await this.page.goto('/login')
+  }
+
+  async login(email: string, password: string) {
+    await this.emailInput.fill(email)
+    await this.passwordInput.fill(password)
+    await this.submitButton.click()
+  }
+}
+```
+
+```ts
+// tests/e2e/auth.spec.ts
+import { test, expect } from '@playwright/test'
+import { LoginPage } from './pages/LoginPage'
+
+test.describe('Authentication', () => {
+  test('successful login redirects to dashboard', async ({ page }) => {
+    const loginPage = new LoginPage(page)
+    await loginPage.goto()
+    await loginPage.login('alice@example.com', 'password123')
+
+    await expect(page).toHaveURL('/dashboard')
+    await expect(page.getByTestId('welcome-message')).toContainText('Alice')
+  })
+
+  test('invalid credentials shows error', async ({ page }) => {
+    const loginPage = new LoginPage(page)
+    await loginPage.goto()
+    await loginPage.login('bad@example.com', 'wrong')
+
+    await expect(loginPage.errorMessage).toBeVisible()
+    await expect(loginPage.errorMessage).toContainText('Invalid credentials')
+  })
+})
+```
+
+### playwright.config.ts
+
+```ts
+import { defineConfig, devices } from '@playwright/test'
+
+export default defineConfig({
+  testDir: './tests/e2e',
+  fullyParallel: true,
+  forbidOnly: !!process.env.CI,
+  retries: process.env.CI ? 2 : 0,
+  workers: process.env.CI ? 1 : undefined,
+  reporter: 'html',
+  use: {
+    baseURL: 'http://localhost:5173',
+    trace: 'on-first-retry',
+  },
+  projects: [
+    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
+    { name: 'Mobile Safari', use: { ...devices['iPhone 13'] } },
+  ],
+  webServer: {
+    command: 'npm run dev',
+    url: 'http://localhost:5173',
+    reuseExistingServer: !process.env.CI,
+  },
+})
+```
+
+---
+
+## Nuxt Testing
+
+### Setup with @nuxt/test-utils
+
+```bash
+npm install -D @nuxt/test-utils vitest @vue/test-utils happy-dom
+```
+
+```ts
+// vitest.config.ts for Nuxt
+import { defineVitestConfig } from '@nuxt/test-utils/config'
+
+export default defineVitestConfig({
+  test: {
+    environment: 'nuxt',    // uses Nuxt-aware environment
+    environmentOptions: {
+      nuxt: {
+        rootDir: '.',
+        overrides: {
+          ssr: false,      // disable SSR for component tests
+        },
+      },
+    },
+  },
+})
+```
+
+### Testing Nuxt components with renderSuspended
+
+```ts
+import { describe, it, expect } from 'vitest'
+import { renderSuspended } from '@nuxt/test-utils/runtime'
+import { screen } from '@testing-library/vue'
+import MyComponent from '@/components/MyComponent.vue'
+
+describe('MyComponent', () => {
+  it('renders correctly', async () => {
+    await renderSuspended(MyComponent, {
+      props: { title: 'Hello Nuxt' },
+    })
+
+    expect(screen.getByText('Hello Nuxt')).toBeDefined()
+  })
+})
+```
+
+### Testing Nuxt composables
+
+```ts
+import { describe, it, expect } from 'vitest'
+import { mountSuspended } from '@nuxt/test-utils/runtime'
+import { defineComponent } from 'vue'
+
+describe('useMyNuxtComposable', () => {
+  it('works with Nuxt context', async () => {
+    const TestComponent = defineComponent({
+      setup() {
+        const state = useState('test', () => 'initial')
+        return { state }
+      },
+      template: '<div>{{ state }}</div>',
+    })
+
+    const wrapper = await mountSuspended(TestComponent)
+    expect(wrapper.text()).toBe('initial')
+  })
+})
+```
+
+---
+
+## Common Testing Pitfalls
+
+| Pitfall | Problem | Fix |
+|---------|---------|-----|
+| Not awaiting `nextTick` | DOM not updated after reactive change | `await nextTick()` or `await wrapper.vm.$nextTick()` after triggering updates |
+| Not awaiting `flushPromises` | Async operations still pending | `await flushPromises()` after triggering async actions |
+| Using `shallowMount` exclusively | Child component bugs hidden | Default to `mount()`, use `shallowMount` only for focused unit tests |
+| Testing implementation details | Brittle tests that break on refactor | Test behavior and output, not internal refs or methods |
+| Missing `setActivePinia` in store tests | Pinia has no active instance | Call `setActivePinia(createPinia())` in `beforeEach` |
+| Forgetting `router.isReady()` | Navigation not complete | Await `router.isReady()` after `router.push()` in tests |
+| Not cleaning up global mocks | Tests pollute each other | Use `afterEach(() => vi.restoreAllMocks())` or `vi.resetAllMocks()` |
+| Querying by text that changes | Brittle to copy changes | Use `data-testid` attributes or ARIA roles for stable selectors |

+ 0 - 0
skills/vue-ops/scripts/.gitkeep