@twelvehart/supermemory-runtime 1.0.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/.env.example +57 -0
  2. package/README.md +374 -0
  3. package/dist/index.js +189 -0
  4. package/dist/mcp/index.js +1132 -0
  5. package/docker-compose.prod.yml +91 -0
  6. package/docker-compose.yml +358 -0
  7. package/drizzle/0000_dapper_the_professor.sql +159 -0
  8. package/drizzle/0001_api_keys.sql +51 -0
  9. package/drizzle/meta/0000_snapshot.json +1532 -0
  10. package/drizzle/meta/_journal.json +13 -0
  11. package/drizzle.config.ts +20 -0
  12. package/package.json +114 -0
  13. package/scripts/add-extraction-job.ts +122 -0
  14. package/scripts/benchmark-pgvector.ts +122 -0
  15. package/scripts/bootstrap.sh +209 -0
  16. package/scripts/check-runtime-pack.ts +111 -0
  17. package/scripts/claude-mcp-config.ts +336 -0
  18. package/scripts/docker-entrypoint.sh +183 -0
  19. package/scripts/doctor.ts +377 -0
  20. package/scripts/init-db.sql +33 -0
  21. package/scripts/install.sh +1110 -0
  22. package/scripts/mcp-setup.ts +271 -0
  23. package/scripts/migrations/001_create_pgvector_extension.sql +31 -0
  24. package/scripts/migrations/002_create_memory_embeddings_table.sql +75 -0
  25. package/scripts/migrations/003_create_hnsw_index.sql +94 -0
  26. package/scripts/migrations/004_create_memory_embeddings_standalone.sql +70 -0
  27. package/scripts/migrations/005_create_chunks_table.sql +95 -0
  28. package/scripts/migrations/006_create_processing_queue.sql +45 -0
  29. package/scripts/migrations/generate_test_data.sql +42 -0
  30. package/scripts/migrations/phase1_comprehensive_test.sql +204 -0
  31. package/scripts/migrations/run_migrations.sh +286 -0
  32. package/scripts/migrations/test_hnsw_index.sql +255 -0
  33. package/scripts/pre-commit-secrets +282 -0
  34. package/scripts/run-extraction-worker.ts +46 -0
  35. package/scripts/run-phase1-tests.sh +291 -0
  36. package/scripts/setup.ts +222 -0
  37. package/scripts/smoke-install.sh +12 -0
  38. package/scripts/test-health-endpoint.sh +328 -0
  39. package/src/api/index.ts +2 -0
  40. package/src/api/middleware/auth.ts +80 -0
  41. package/src/api/middleware/csrf.ts +308 -0
  42. package/src/api/middleware/errorHandler.ts +166 -0
  43. package/src/api/middleware/rateLimit.ts +360 -0
  44. package/src/api/middleware/validation.ts +514 -0
  45. package/src/api/routes/documents.ts +286 -0
  46. package/src/api/routes/profiles.ts +237 -0
  47. package/src/api/routes/search.ts +71 -0
  48. package/src/api/stores/index.ts +58 -0
  49. package/src/config/bootstrap-env.ts +3 -0
  50. package/src/config/env.ts +71 -0
  51. package/src/config/feature-flags.ts +25 -0
  52. package/src/config/index.ts +140 -0
  53. package/src/config/secrets.config.ts +291 -0
  54. package/src/db/client.ts +92 -0
  55. package/src/db/index.ts +73 -0
  56. package/src/db/postgres.ts +72 -0
  57. package/src/db/schema/chunks.schema.ts +31 -0
  58. package/src/db/schema/containers.schema.ts +46 -0
  59. package/src/db/schema/documents.schema.ts +49 -0
  60. package/src/db/schema/embeddings.schema.ts +32 -0
  61. package/src/db/schema/index.ts +11 -0
  62. package/src/db/schema/memories.schema.ts +72 -0
  63. package/src/db/schema/profiles.schema.ts +34 -0
  64. package/src/db/schema/queue.schema.ts +59 -0
  65. package/src/db/schema/relationships.schema.ts +42 -0
  66. package/src/db/schema.ts +223 -0
  67. package/src/db/worker-connection.ts +47 -0
  68. package/src/index.ts +235 -0
  69. package/src/mcp/CLAUDE.md +1 -0
  70. package/src/mcp/index.ts +1380 -0
  71. package/src/mcp/legacyState.ts +22 -0
  72. package/src/mcp/rateLimit.ts +358 -0
  73. package/src/mcp/resources.ts +309 -0
  74. package/src/mcp/results.ts +104 -0
  75. package/src/mcp/tools.ts +401 -0
  76. package/src/queues/config.ts +119 -0
  77. package/src/queues/index.ts +289 -0
  78. package/src/sdk/client.ts +225 -0
  79. package/src/sdk/errors.ts +266 -0
  80. package/src/sdk/http.ts +560 -0
  81. package/src/sdk/index.ts +244 -0
  82. package/src/sdk/resources/base.ts +65 -0
  83. package/src/sdk/resources/connections.ts +204 -0
  84. package/src/sdk/resources/documents.ts +163 -0
  85. package/src/sdk/resources/index.ts +10 -0
  86. package/src/sdk/resources/memories.ts +150 -0
  87. package/src/sdk/resources/search.ts +60 -0
  88. package/src/sdk/resources/settings.ts +36 -0
  89. package/src/sdk/types.ts +674 -0
  90. package/src/services/chunking/index.ts +451 -0
  91. package/src/services/chunking.service.ts +650 -0
  92. package/src/services/csrf.service.ts +252 -0
  93. package/src/services/documents.repository.ts +219 -0
  94. package/src/services/documents.service.ts +191 -0
  95. package/src/services/embedding.service.ts +404 -0
  96. package/src/services/extraction.service.ts +300 -0
  97. package/src/services/extractors/code.extractor.ts +451 -0
  98. package/src/services/extractors/index.ts +9 -0
  99. package/src/services/extractors/markdown.extractor.ts +461 -0
  100. package/src/services/extractors/pdf.extractor.ts +315 -0
  101. package/src/services/extractors/text.extractor.ts +118 -0
  102. package/src/services/extractors/url.extractor.ts +243 -0
  103. package/src/services/index.ts +235 -0
  104. package/src/services/ingestion.service.ts +177 -0
  105. package/src/services/llm/anthropic.ts +400 -0
  106. package/src/services/llm/base.ts +460 -0
  107. package/src/services/llm/contradiction-detector.service.ts +526 -0
  108. package/src/services/llm/heuristics.ts +148 -0
  109. package/src/services/llm/index.ts +309 -0
  110. package/src/services/llm/memory-classifier.service.ts +383 -0
  111. package/src/services/llm/memory-extension-detector.service.ts +523 -0
  112. package/src/services/llm/mock.ts +470 -0
  113. package/src/services/llm/openai.ts +398 -0
  114. package/src/services/llm/prompts.ts +438 -0
  115. package/src/services/llm/types.ts +373 -0
  116. package/src/services/memory.repository.ts +1769 -0
  117. package/src/services/memory.service.ts +1338 -0
  118. package/src/services/memory.types.ts +234 -0
  119. package/src/services/persistence/index.ts +295 -0
  120. package/src/services/pipeline.service.ts +509 -0
  121. package/src/services/profile.repository.ts +436 -0
  122. package/src/services/profile.service.ts +560 -0
  123. package/src/services/profile.types.ts +270 -0
  124. package/src/services/relationships/detector.ts +1128 -0
  125. package/src/services/relationships/index.ts +268 -0
  126. package/src/services/relationships/memory-integration.ts +459 -0
  127. package/src/services/relationships/strategies.ts +132 -0
  128. package/src/services/relationships/types.ts +370 -0
  129. package/src/services/search.service.ts +761 -0
  130. package/src/services/search.types.ts +220 -0
  131. package/src/services/secrets.service.ts +384 -0
  132. package/src/services/vectorstore/base.ts +327 -0
  133. package/src/services/vectorstore/index.ts +444 -0
  134. package/src/services/vectorstore/memory.ts +286 -0
  135. package/src/services/vectorstore/migration.ts +295 -0
  136. package/src/services/vectorstore/mock.ts +403 -0
  137. package/src/services/vectorstore/pgvector.ts +695 -0
  138. package/src/services/vectorstore/types.ts +247 -0
  139. package/src/startup.ts +389 -0
  140. package/src/types/api.types.ts +193 -0
  141. package/src/types/document.types.ts +103 -0
  142. package/src/types/index.ts +241 -0
  143. package/src/types/profile.base.ts +133 -0
  144. package/src/utils/errors.ts +447 -0
  145. package/src/utils/id.ts +15 -0
  146. package/src/utils/index.ts +101 -0
  147. package/src/utils/logger.ts +313 -0
  148. package/src/utils/sanitization.ts +501 -0
  149. package/src/utils/secret-validation.ts +273 -0
  150. package/src/utils/synonyms.ts +188 -0
  151. package/src/utils/validation.ts +581 -0
  152. package/src/workers/chunking.worker.ts +242 -0
  153. package/src/workers/embedding.worker.ts +358 -0
  154. package/src/workers/extraction.worker.ts +346 -0
  155. package/src/workers/indexing.worker.ts +505 -0
  156. package/tsconfig.json +38 -0
@@ -0,0 +1,328 @@
1
+ #!/bin/bash
2
+
3
+ # =============================================================================
4
+ # Health Endpoint Test Script
5
+ # =============================================================================
6
+ # This script validates the /health endpoint implementation and Docker
7
+ # health check integration.
8
+ #
9
+ # Usage:
10
+ # ./scripts/test-health-endpoint.sh [--docker]
11
+ #
12
+ # Options:
13
+ # --docker Test Docker health checks instead of local endpoint
14
+ # =============================================================================
15
+
16
+ set -e
17
+
18
+ # Colors for output
19
+ RED='\033[0;31m'
20
+ GREEN='\033[0;32m'
21
+ YELLOW='\033[1;33m'
22
+ BLUE='\033[0;34m'
23
+ NC='\033[0m' # No Color
24
+
25
+ # Configuration
26
+ API_URL="${API_URL:-http://localhost:3000}"
27
+ HEALTH_ENDPOINT="${API_URL}/health"
28
+ DOCKER_MODE=false
29
+
30
+ # Parse arguments
31
+ if [[ "$1" == "--docker" ]]; then
32
+ DOCKER_MODE=true
33
+ fi
34
+
35
+ # Helper functions
36
+ print_header() {
37
+ echo -e "\n${BLUE}==============================================================================${NC}"
38
+ echo -e "${BLUE}$1${NC}"
39
+ echo -e "${BLUE}==============================================================================${NC}\n"
40
+ }
41
+
42
+ print_success() {
43
+ echo -e "${GREEN}✓ $1${NC}"
44
+ }
45
+
46
+ print_error() {
47
+ echo -e "${RED}✗ $1${NC}"
48
+ }
49
+
50
+ print_warning() {
51
+ echo -e "${YELLOW}⚠ $1${NC}"
52
+ }
53
+
54
+ print_info() {
55
+ echo -e "${BLUE}ℹ $1${NC}"
56
+ }
57
+
58
+ # Test 1: Check if endpoint is accessible
59
+ test_endpoint_accessible() {
60
+ print_header "TEST 1: Endpoint Accessibility"
61
+
62
+ if curl -f -s -o /dev/null -w "%{http_code}" "${HEALTH_ENDPOINT}" > /dev/null 2>&1; then
63
+ print_success "Health endpoint is accessible"
64
+ return 0
65
+ else
66
+ print_error "Health endpoint is not accessible"
67
+ print_info "Make sure the API server is running at ${API_URL}"
68
+ return 1
69
+ fi
70
+ }
71
+
72
+ # Test 2: Validate response format
73
+ test_response_format() {
74
+ print_header "TEST 2: Response Format Validation"
75
+
76
+ local response=$(curl -s "${HEALTH_ENDPOINT}")
77
+ local status_code=$(curl -s -o /dev/null -w "%{http_code}" "${HEALTH_ENDPOINT}")
78
+
79
+ print_info "HTTP Status Code: ${status_code}"
80
+ print_info "Response Body:"
81
+ echo "${response}" | jq . 2>/dev/null || echo "${response}"
82
+
83
+ # Check if response is valid JSON
84
+ if echo "${response}" | jq . > /dev/null 2>&1; then
85
+ print_success "Response is valid JSON"
86
+ else
87
+ print_error "Response is not valid JSON"
88
+ return 1
89
+ fi
90
+
91
+ # Check required fields
92
+ local required_fields=("timestamp" "status" "version" "database" "uptime")
93
+ for field in "${required_fields[@]}"; do
94
+ if echo "${response}" | jq -e ".${field}" > /dev/null 2>&1; then
95
+ print_success "Field '${field}' is present"
96
+ else
97
+ print_error "Field '${field}' is missing"
98
+ return 1
99
+ fi
100
+ done
101
+
102
+ # Validate status field value
103
+ local status=$(echo "${response}" | jq -r '.status')
104
+ if [[ "${status}" == "healthy" ]] || [[ "${status}" == "unhealthy" ]]; then
105
+ print_success "Status field has valid value: ${status}"
106
+ else
107
+ print_error "Status field has invalid value: ${status}"
108
+ return 1
109
+ fi
110
+
111
+ return 0
112
+ }
113
+
114
+ # Test 3: Check HTTP status codes
115
+ test_status_codes() {
116
+ print_header "TEST 3: HTTP Status Code Validation"
117
+
118
+ local status_code=$(curl -s -o /dev/null -w "%{http_code}" "${HEALTH_ENDPOINT}")
119
+
120
+ if [[ "${status_code}" == "200" ]]; then
121
+ print_success "Healthy state returns 200 OK"
122
+ elif [[ "${status_code}" == "503" ]]; then
123
+ print_warning "Service is unhealthy (503 Service Unavailable)"
124
+ print_info "This may indicate a database connectivity issue"
125
+ else
126
+ print_error "Unexpected status code: ${status_code}"
127
+ return 1
128
+ fi
129
+
130
+ return 0
131
+ }
132
+
133
+ # Test 4: Check database connectivity
134
+ test_database_connectivity() {
135
+ print_header "TEST 4: Database Connectivity Check"
136
+
137
+ local response=$(curl -s "${HEALTH_ENDPOINT}")
138
+ local db_status=$(echo "${response}" | jq -r '.database')
139
+
140
+ if [[ "${db_status}" == "connected" ]]; then
141
+ print_success "Database is connected"
142
+ elif [[ "${db_status}" == "disconnected" ]]; then
143
+ print_error "Database is disconnected"
144
+ return 1
145
+ elif [[ "${db_status}" == "not_initialized" ]]; then
146
+ print_error "Database is not initialized"
147
+ return 1
148
+ else
149
+ print_warning "Database status is unknown: ${db_status}"
150
+ return 1
151
+ fi
152
+
153
+ return 0
154
+ }
155
+
156
+ # Test 5: Validate uptime field
157
+ test_uptime_field() {
158
+ print_header "TEST 5: Uptime Field Validation"
159
+
160
+ local response=$(curl -s "${HEALTH_ENDPOINT}")
161
+ local uptime=$(echo "${response}" | jq -r '.uptime')
162
+
163
+ # Check if uptime is a number
164
+ if [[ "${uptime}" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
165
+ print_success "Uptime is a valid number: ${uptime} seconds"
166
+
167
+ # Convert to human-readable format
168
+ local hours=$((${uptime%.*} / 3600))
169
+ local minutes=$(((${uptime%.*} % 3600) / 60))
170
+ local seconds=$((${uptime%.*} % 60))
171
+ print_info "Process uptime: ${hours}h ${minutes}m ${seconds}s"
172
+ else
173
+ print_error "Uptime is not a valid number: ${uptime}"
174
+ return 1
175
+ fi
176
+
177
+ return 0
178
+ }
179
+
180
+ # Test 6: Response time check
181
+ test_response_time() {
182
+ print_header "TEST 6: Response Time Check"
183
+
184
+ local response_time=$(curl -o /dev/null -s -w '%{time_total}' "${HEALTH_ENDPOINT}")
185
+
186
+ print_info "Response time: ${response_time} seconds"
187
+
188
+ # Convert to milliseconds
189
+ local response_ms=$(echo "${response_time} * 1000" | bc)
190
+
191
+ if (( $(echo "${response_ms} < 100" | bc -l) )); then
192
+ print_success "Response time is excellent (< 100ms)"
193
+ elif (( $(echo "${response_ms} < 500" | bc -l) )); then
194
+ print_success "Response time is good (< 500ms)"
195
+ elif (( $(echo "${response_ms} < 1000" | bc -l) )); then
196
+ print_warning "Response time is acceptable (< 1s)"
197
+ else
198
+ print_error "Response time is too slow (> 1s)"
199
+ return 1
200
+ fi
201
+
202
+ return 0
203
+ }
204
+
205
+ # Docker-specific tests
206
+ test_docker_health() {
207
+ print_header "DOCKER: Container Health Status"
208
+
209
+ if ! command -v docker &> /dev/null; then
210
+ print_error "Docker is not installed"
211
+ return 1
212
+ fi
213
+
214
+ # Check if container is running
215
+ if docker compose ps api 2>/dev/null | grep -q "Up"; then
216
+ print_success "Container is running"
217
+ else
218
+ print_error "Container is not running"
219
+ print_info "Start the container with: docker compose up -d api"
220
+ return 1
221
+ fi
222
+
223
+ # Check health status
224
+ local health_status=$(docker inspect supermemory-api --format='{{.State.Health.Status}}' 2>/dev/null || echo "none")
225
+
226
+ print_info "Container health status: ${health_status}"
227
+
228
+ if [[ "${health_status}" == "healthy" ]]; then
229
+ print_success "Container is healthy"
230
+ elif [[ "${health_status}" == "unhealthy" ]]; then
231
+ print_error "Container is unhealthy"
232
+ print_info "Check logs with: docker compose logs api"
233
+ return 1
234
+ elif [[ "${health_status}" == "starting" ]]; then
235
+ print_warning "Container is still starting (within start_period)"
236
+ print_info "Wait a few more seconds and try again"
237
+ else
238
+ print_warning "No health status available (health check may not be configured)"
239
+ fi
240
+
241
+ return 0
242
+ }
243
+
244
+ test_docker_health_logs() {
245
+ print_header "DOCKER: Health Check Logs"
246
+
247
+ local health_log=$(docker inspect supermemory-api --format='{{json .State.Health}}' 2>/dev/null)
248
+
249
+ if [[ -n "${health_log}" ]]; then
250
+ echo "${health_log}" | jq . || echo "${health_log}"
251
+ print_success "Health check logs retrieved"
252
+ else
253
+ print_warning "No health check logs available"
254
+ fi
255
+
256
+ return 0
257
+ }
258
+
259
+ # Main execution
260
+ main() {
261
+ echo -e "${GREEN}"
262
+ echo " ╔═══════════════════════════════════════════════════════════════╗"
263
+ echo " ║ ║"
264
+ echo " ║ SuperMemory Clone - Health Endpoint Tests ║"
265
+ echo " ║ ║"
266
+ echo " ╚═══════════════════════════════════════════════════════════════╝"
267
+ echo -e "${NC}"
268
+
269
+ local total_tests=0
270
+ local passed_tests=0
271
+ local failed_tests=0
272
+
273
+ run_test() {
274
+ local test_name="$1"
275
+ total_tests=$((total_tests + 1))
276
+
277
+ if $test_name; then
278
+ passed_tests=$((passed_tests + 1))
279
+ else
280
+ failed_tests=$((failed_tests + 1))
281
+ fi
282
+ }
283
+
284
+ if [[ "${DOCKER_MODE}" == true ]]; then
285
+ # Run Docker-specific tests
286
+ run_test test_docker_health
287
+ run_test test_docker_health_logs
288
+ run_test test_endpoint_accessible
289
+ run_test test_response_format
290
+ else
291
+ # Run standard tests
292
+ run_test test_endpoint_accessible
293
+ run_test test_response_format
294
+ run_test test_status_codes
295
+ run_test test_database_connectivity
296
+ run_test test_uptime_field
297
+ run_test test_response_time
298
+ fi
299
+
300
+ # Print summary
301
+ print_header "TEST SUMMARY"
302
+ echo -e "Total Tests: ${total_tests}"
303
+ echo -e "Passed: ${GREEN}${passed_tests}${NC}"
304
+ echo -e "Failed: ${RED}${failed_tests}${NC}"
305
+
306
+ if [[ ${failed_tests} -eq 0 ]]; then
307
+ echo -e "\n${GREEN}✓ All tests passed!${NC}\n"
308
+ exit 0
309
+ else
310
+ echo -e "\n${RED}✗ Some tests failed${NC}\n"
311
+ exit 1
312
+ fi
313
+ }
314
+
315
+ # Check dependencies
316
+ if ! command -v curl &> /dev/null; then
317
+ print_error "curl is not installed"
318
+ exit 1
319
+ fi
320
+
321
+ if ! command -v jq &> /dev/null; then
322
+ print_error "jq is not installed"
323
+ print_info "Install with: brew install jq (macOS) or apt-get install jq (Ubuntu)"
324
+ exit 1
325
+ fi
326
+
327
+ # Run main function
328
+ main
@@ -0,0 +1,2 @@
1
+ // Deprecated API entrypoint. Use src/index.ts instead.
2
+ export { app } from '../index.js'
@@ -0,0 +1,80 @@
1
+ import { Context, MiddlewareHandler } from 'hono'
2
+ import { AuthContext, ErrorCodes } from '../../types/api.types.js'
3
+
4
+ function isAuthEnabled(): boolean {
5
+ const raw = process.env.AUTH_ENABLED
6
+ return raw === 'true' || raw === '1'
7
+ }
8
+
9
+ declare module 'hono' {
10
+ interface ContextVariableMap {
11
+ auth: AuthContext
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Minimal optional bearer-token auth middleware.
17
+ * - AUTH_ENABLED=false (default): pass-through
18
+ * - AUTH_ENABLED=true: require Authorization: Bearer <AUTH_TOKEN>
19
+ */
20
+ export const authMiddleware: MiddlewareHandler = async (c: Context, next) => {
21
+ if (!isAuthEnabled()) {
22
+ c.set('auth', { userId: 'anonymous', apiKey: '', scopes: ['*'] })
23
+ return next()
24
+ }
25
+
26
+ const configuredToken = process.env.AUTH_TOKEN
27
+ if (!configuredToken) {
28
+ return c.json(
29
+ {
30
+ error: {
31
+ code: ErrorCodes.INTERNAL_ERROR,
32
+ message: 'AUTH_TOKEN is required when AUTH_ENABLED=true',
33
+ },
34
+ status: 500,
35
+ },
36
+ 500
37
+ )
38
+ }
39
+
40
+ const authHeader = c.req.header('Authorization')
41
+ if (!authHeader?.startsWith('Bearer ')) {
42
+ return c.json(
43
+ {
44
+ error: {
45
+ code: ErrorCodes.UNAUTHORIZED,
46
+ message: 'Authorization header is required (Bearer token)',
47
+ },
48
+ status: 401,
49
+ },
50
+ 401
51
+ )
52
+ }
53
+
54
+ const token = authHeader.slice('Bearer '.length).trim()
55
+ if (!token || token !== configuredToken) {
56
+ return c.json(
57
+ {
58
+ error: {
59
+ code: ErrorCodes.UNAUTHORIZED,
60
+ message: 'Invalid authentication token',
61
+ },
62
+ status: 401,
63
+ },
64
+ 401
65
+ )
66
+ }
67
+
68
+ c.set('auth', { userId: 'authenticated', apiKey: token, scopes: ['*'] })
69
+ return next()
70
+ }
71
+
72
+ /**
73
+ * Scope checks are intentionally no-op in minimal auth mode.
74
+ * Kept for backward compatibility with route declarations.
75
+ */
76
+ export const requireScopes = (..._requiredScopes: string[]): MiddlewareHandler => {
77
+ return async (_c: Context, next) => {
78
+ return next()
79
+ }
80
+ }
@@ -0,0 +1,308 @@
1
+ import { Context, MiddlewareHandler } from 'hono'
2
+ import { getCookie, setCookie } from 'hono/cookie'
3
+ import { createCsrfService, CsrfService } from '../../services/csrf.service.js'
4
+ import { ErrorCodes } from '../../types/api.types.js'
5
+ import { getLogger } from '../../utils/logger.js'
6
+
7
+ const logger = getLogger('csrf-middleware')
8
+
9
+ /**
10
+ * CSRF Protection Middleware for Hono
11
+ *
12
+ * Implements double-submit cookie pattern with:
13
+ * - Cryptographically secure token generation
14
+ * - HMAC-SHA256 signing
15
+ * - Constant-time comparison
16
+ * - SameSite=Strict cookies
17
+ * - Origin/Referer validation
18
+ * - Safe method exemption (GET, HEAD, OPTIONS)
19
+ *
20
+ * Security features:
21
+ * - Secure flag in production
22
+ * - HttpOnly flag always
23
+ * - Origin whitelist validation
24
+ * - 403 Forbidden for CSRF failures
25
+ */
26
+
27
+ export interface CsrfConfig {
28
+ cookieName?: string
29
+ headerName?: string
30
+ allowedOrigins?: string[]
31
+ exemptMethods?: string[]
32
+ cookieOptions?: {
33
+ secure?: boolean
34
+ httpOnly?: boolean
35
+ sameSite?: 'Strict' | 'Lax' | 'None'
36
+ maxAge?: number
37
+ }
38
+ }
39
+
40
+ const DEFAULT_CONFIG: Required<CsrfConfig> = {
41
+ cookieName: '_csrf',
42
+ headerName: 'X-CSRF-Token',
43
+ allowedOrigins: [],
44
+ exemptMethods: ['GET', 'HEAD', 'OPTIONS'],
45
+ cookieOptions: {
46
+ secure: process.env.NODE_ENV === 'production',
47
+ httpOnly: true,
48
+ sameSite: 'Strict',
49
+ maxAge: 60 * 60, // 1 hour in seconds
50
+ },
51
+ }
52
+
53
+ // Global CSRF service instance
54
+ let csrfService: CsrfService | null = null
55
+
56
+ /**
57
+ * Get or create the global CSRF service instance.
58
+ */
59
+ function getCsrfService(): CsrfService {
60
+ if (!csrfService) {
61
+ csrfService = createCsrfService()
62
+ }
63
+ return csrfService
64
+ }
65
+
66
+ /**
67
+ * CSRF protection middleware.
68
+ * Validates CSRF tokens for state-changing requests.
69
+ */
70
+ export const csrfProtection = (config: CsrfConfig = {}): MiddlewareHandler => {
71
+ const cfg = { ...DEFAULT_CONFIG, ...config }
72
+ const service = getCsrfService()
73
+
74
+ return async (c: Context, next) => {
75
+ const method = c.req.method.toUpperCase()
76
+
77
+ // Exempt safe methods (GET, HEAD, OPTIONS)
78
+ if (cfg.exemptMethods.includes(method)) {
79
+ return next()
80
+ }
81
+
82
+ // Validate origin/referer for non-exempted methods
83
+ if (!validateOrigin(c, cfg.allowedOrigins)) {
84
+ return c.json(
85
+ {
86
+ error: {
87
+ code: ErrorCodes.FORBIDDEN,
88
+ message: 'Invalid origin or referer',
89
+ },
90
+ status: 403,
91
+ },
92
+ 403
93
+ )
94
+ }
95
+
96
+ // Get token from cookie
97
+ const cookieToken = getCookie(c, cfg.cookieName)
98
+
99
+ if (!cookieToken) {
100
+ return c.json(
101
+ {
102
+ error: {
103
+ code: ErrorCodes.FORBIDDEN,
104
+ message: 'CSRF token missing in cookie',
105
+ },
106
+ status: 403,
107
+ },
108
+ 403
109
+ )
110
+ }
111
+
112
+ // Parse cookie token (format: token:signature)
113
+ const cookieParts = cookieToken.split(':')
114
+ if (cookieParts.length !== 2) {
115
+ return c.json(
116
+ {
117
+ error: {
118
+ code: ErrorCodes.FORBIDDEN,
119
+ message: 'Invalid CSRF token format',
120
+ },
121
+ status: 403,
122
+ },
123
+ 403
124
+ )
125
+ }
126
+
127
+ const [cookieTokenValue, cookieSignature] = cookieParts
128
+
129
+ // Get token from header or form data
130
+ let headerToken = c.req.header(cfg.headerName)
131
+
132
+ // If not in header, try to get from form data (for traditional forms)
133
+ if (!headerToken && c.req.header('content-type')?.includes('application/x-www-form-urlencoded')) {
134
+ try {
135
+ const body = await c.req.parseBody()
136
+ headerToken = body._csrf as string
137
+ } catch {
138
+ // Ignore parsing errors
139
+ }
140
+ }
141
+
142
+ if (!headerToken) {
143
+ return c.json(
144
+ {
145
+ error: {
146
+ code: ErrorCodes.FORBIDDEN,
147
+ message: 'CSRF token missing in request',
148
+ },
149
+ status: 403,
150
+ },
151
+ 403
152
+ )
153
+ }
154
+
155
+ // Validate token (double-submit pattern: cookie token must match header token)
156
+ if (cookieTokenValue !== headerToken) {
157
+ return c.json(
158
+ {
159
+ error: {
160
+ code: ErrorCodes.FORBIDDEN,
161
+ message: 'CSRF token mismatch',
162
+ },
163
+ status: 403,
164
+ },
165
+ 403
166
+ )
167
+ }
168
+
169
+ // Validate token signature and expiration
170
+ const isValid = service.validateToken(headerToken, cookieSignature ?? '')
171
+
172
+ if (!isValid) {
173
+ return c.json(
174
+ {
175
+ error: {
176
+ code: ErrorCodes.FORBIDDEN,
177
+ message: 'Invalid or expired CSRF token',
178
+ },
179
+ status: 403,
180
+ },
181
+ 403
182
+ )
183
+ }
184
+
185
+ // Token is valid, proceed with request
186
+ return next()
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Middleware to set CSRF cookie for clients.
192
+ * Should be applied before CSRF protection middleware.
193
+ */
194
+ export const setCsrfCookie = (config: CsrfConfig = {}): MiddlewareHandler => {
195
+ const cfg = { ...DEFAULT_CONFIG, ...config }
196
+ const service = getCsrfService()
197
+
198
+ return async (c: Context, next) => {
199
+ const issueCsrfCookie = (): void => {
200
+ const csrfToken = service.generateToken()
201
+ const cookieValue = `${csrfToken.token}:${csrfToken.signature}`
202
+
203
+ setCookie(c, cfg.cookieName, cookieValue, {
204
+ httpOnly: cfg.cookieOptions.httpOnly,
205
+ secure: cfg.cookieOptions.secure,
206
+ sameSite: cfg.cookieOptions.sameSite,
207
+ maxAge: cfg.cookieOptions.maxAge,
208
+ path: '/',
209
+ })
210
+
211
+ c.set('csrfToken', csrfToken.token)
212
+ }
213
+
214
+ // Check if cookie already exists
215
+ const existingCookie = getCookie(c, cfg.cookieName)
216
+
217
+ if (!existingCookie) {
218
+ issueCsrfCookie()
219
+ } else {
220
+ const [token, signature, ...remainder] = existingCookie.split(':')
221
+ const hasValidFormat = remainder.length === 0 && !!token && !!signature
222
+
223
+ if (!hasValidFormat) {
224
+ issueCsrfCookie()
225
+ } else {
226
+ // Extract token from existing cookie
227
+ c.set('csrfToken', token)
228
+ }
229
+ }
230
+
231
+ return next()
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Validate request origin/referer against whitelist.
237
+ *
238
+ * @param c - Hono context
239
+ * @param allowedOrigins - List of allowed origins
240
+ * @returns True if origin is valid
241
+ */
242
+ function validateOrigin(c: Context, allowedOrigins: string[]): boolean {
243
+ // If no whitelist is configured, skip validation
244
+ if (allowedOrigins.length === 0) {
245
+ return true
246
+ }
247
+
248
+ // Get origin from Origin or Referer header
249
+ const origin = c.req.header('origin')
250
+ const referer = c.req.header('referer')
251
+
252
+ // If neither header is present, require explicit opt-in
253
+ if (!origin && !referer) {
254
+ // Explicit opt-in for missing origin/referer (dev/test environments)
255
+ // Use environment variable CSRF_ALLOW_MISSING_ORIGIN=true to enable
256
+ const allowMissing = process.env.CSRF_ALLOW_MISSING_ORIGIN === 'true'
257
+
258
+ if (!allowMissing && process.env.NODE_ENV === 'production') {
259
+ logger.warn('Blocked request with missing Origin and Referer headers in production')
260
+ return false
261
+ }
262
+
263
+ if (allowMissing && process.env.NODE_ENV !== 'production') {
264
+ logger.debug('Allowing request with missing Origin/Referer (dev mode)')
265
+ return true
266
+ }
267
+
268
+ return false
269
+ }
270
+
271
+ // Validate origin
272
+ if (origin && allowedOrigins.includes(origin)) {
273
+ return true
274
+ }
275
+
276
+ // Validate referer (extract origin from referer URL)
277
+ if (referer) {
278
+ try {
279
+ const refererUrl = new URL(referer)
280
+ const refererOrigin = `${refererUrl.protocol}//${refererUrl.host}`
281
+
282
+ if (allowedOrigins.includes(refererOrigin)) {
283
+ return true
284
+ }
285
+ } catch {
286
+ // Invalid referer URL
287
+ return false
288
+ }
289
+ }
290
+
291
+ return false
292
+ }
293
+
294
+ /**
295
+ * Extend Hono context to include CSRF token.
296
+ */
297
+ declare module 'hono' {
298
+ interface ContextVariableMap {
299
+ csrfToken?: string
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Helper to get CSRF token from context (for rendering in templates).
305
+ */
306
+ export function getCsrfToken(c: Context): string | undefined {
307
+ return c.get('csrfToken')
308
+ }