@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.
- package/.env.example +57 -0
- package/README.md +374 -0
- package/dist/index.js +189 -0
- package/dist/mcp/index.js +1132 -0
- package/docker-compose.prod.yml +91 -0
- package/docker-compose.yml +358 -0
- package/drizzle/0000_dapper_the_professor.sql +159 -0
- package/drizzle/0001_api_keys.sql +51 -0
- package/drizzle/meta/0000_snapshot.json +1532 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +20 -0
- package/package.json +114 -0
- package/scripts/add-extraction-job.ts +122 -0
- package/scripts/benchmark-pgvector.ts +122 -0
- package/scripts/bootstrap.sh +209 -0
- package/scripts/check-runtime-pack.ts +111 -0
- package/scripts/claude-mcp-config.ts +336 -0
- package/scripts/docker-entrypoint.sh +183 -0
- package/scripts/doctor.ts +377 -0
- package/scripts/init-db.sql +33 -0
- package/scripts/install.sh +1110 -0
- package/scripts/mcp-setup.ts +271 -0
- package/scripts/migrations/001_create_pgvector_extension.sql +31 -0
- package/scripts/migrations/002_create_memory_embeddings_table.sql +75 -0
- package/scripts/migrations/003_create_hnsw_index.sql +94 -0
- package/scripts/migrations/004_create_memory_embeddings_standalone.sql +70 -0
- package/scripts/migrations/005_create_chunks_table.sql +95 -0
- package/scripts/migrations/006_create_processing_queue.sql +45 -0
- package/scripts/migrations/generate_test_data.sql +42 -0
- package/scripts/migrations/phase1_comprehensive_test.sql +204 -0
- package/scripts/migrations/run_migrations.sh +286 -0
- package/scripts/migrations/test_hnsw_index.sql +255 -0
- package/scripts/pre-commit-secrets +282 -0
- package/scripts/run-extraction-worker.ts +46 -0
- package/scripts/run-phase1-tests.sh +291 -0
- package/scripts/setup.ts +222 -0
- package/scripts/smoke-install.sh +12 -0
- package/scripts/test-health-endpoint.sh +328 -0
- package/src/api/index.ts +2 -0
- package/src/api/middleware/auth.ts +80 -0
- package/src/api/middleware/csrf.ts +308 -0
- package/src/api/middleware/errorHandler.ts +166 -0
- package/src/api/middleware/rateLimit.ts +360 -0
- package/src/api/middleware/validation.ts +514 -0
- package/src/api/routes/documents.ts +286 -0
- package/src/api/routes/profiles.ts +237 -0
- package/src/api/routes/search.ts +71 -0
- package/src/api/stores/index.ts +58 -0
- package/src/config/bootstrap-env.ts +3 -0
- package/src/config/env.ts +71 -0
- package/src/config/feature-flags.ts +25 -0
- package/src/config/index.ts +140 -0
- package/src/config/secrets.config.ts +291 -0
- package/src/db/client.ts +92 -0
- package/src/db/index.ts +73 -0
- package/src/db/postgres.ts +72 -0
- package/src/db/schema/chunks.schema.ts +31 -0
- package/src/db/schema/containers.schema.ts +46 -0
- package/src/db/schema/documents.schema.ts +49 -0
- package/src/db/schema/embeddings.schema.ts +32 -0
- package/src/db/schema/index.ts +11 -0
- package/src/db/schema/memories.schema.ts +72 -0
- package/src/db/schema/profiles.schema.ts +34 -0
- package/src/db/schema/queue.schema.ts +59 -0
- package/src/db/schema/relationships.schema.ts +42 -0
- package/src/db/schema.ts +223 -0
- package/src/db/worker-connection.ts +47 -0
- package/src/index.ts +235 -0
- package/src/mcp/CLAUDE.md +1 -0
- package/src/mcp/index.ts +1380 -0
- package/src/mcp/legacyState.ts +22 -0
- package/src/mcp/rateLimit.ts +358 -0
- package/src/mcp/resources.ts +309 -0
- package/src/mcp/results.ts +104 -0
- package/src/mcp/tools.ts +401 -0
- package/src/queues/config.ts +119 -0
- package/src/queues/index.ts +289 -0
- package/src/sdk/client.ts +225 -0
- package/src/sdk/errors.ts +266 -0
- package/src/sdk/http.ts +560 -0
- package/src/sdk/index.ts +244 -0
- package/src/sdk/resources/base.ts +65 -0
- package/src/sdk/resources/connections.ts +204 -0
- package/src/sdk/resources/documents.ts +163 -0
- package/src/sdk/resources/index.ts +10 -0
- package/src/sdk/resources/memories.ts +150 -0
- package/src/sdk/resources/search.ts +60 -0
- package/src/sdk/resources/settings.ts +36 -0
- package/src/sdk/types.ts +674 -0
- package/src/services/chunking/index.ts +451 -0
- package/src/services/chunking.service.ts +650 -0
- package/src/services/csrf.service.ts +252 -0
- package/src/services/documents.repository.ts +219 -0
- package/src/services/documents.service.ts +191 -0
- package/src/services/embedding.service.ts +404 -0
- package/src/services/extraction.service.ts +300 -0
- package/src/services/extractors/code.extractor.ts +451 -0
- package/src/services/extractors/index.ts +9 -0
- package/src/services/extractors/markdown.extractor.ts +461 -0
- package/src/services/extractors/pdf.extractor.ts +315 -0
- package/src/services/extractors/text.extractor.ts +118 -0
- package/src/services/extractors/url.extractor.ts +243 -0
- package/src/services/index.ts +235 -0
- package/src/services/ingestion.service.ts +177 -0
- package/src/services/llm/anthropic.ts +400 -0
- package/src/services/llm/base.ts +460 -0
- package/src/services/llm/contradiction-detector.service.ts +526 -0
- package/src/services/llm/heuristics.ts +148 -0
- package/src/services/llm/index.ts +309 -0
- package/src/services/llm/memory-classifier.service.ts +383 -0
- package/src/services/llm/memory-extension-detector.service.ts +523 -0
- package/src/services/llm/mock.ts +470 -0
- package/src/services/llm/openai.ts +398 -0
- package/src/services/llm/prompts.ts +438 -0
- package/src/services/llm/types.ts +373 -0
- package/src/services/memory.repository.ts +1769 -0
- package/src/services/memory.service.ts +1338 -0
- package/src/services/memory.types.ts +234 -0
- package/src/services/persistence/index.ts +295 -0
- package/src/services/pipeline.service.ts +509 -0
- package/src/services/profile.repository.ts +436 -0
- package/src/services/profile.service.ts +560 -0
- package/src/services/profile.types.ts +270 -0
- package/src/services/relationships/detector.ts +1128 -0
- package/src/services/relationships/index.ts +268 -0
- package/src/services/relationships/memory-integration.ts +459 -0
- package/src/services/relationships/strategies.ts +132 -0
- package/src/services/relationships/types.ts +370 -0
- package/src/services/search.service.ts +761 -0
- package/src/services/search.types.ts +220 -0
- package/src/services/secrets.service.ts +384 -0
- package/src/services/vectorstore/base.ts +327 -0
- package/src/services/vectorstore/index.ts +444 -0
- package/src/services/vectorstore/memory.ts +286 -0
- package/src/services/vectorstore/migration.ts +295 -0
- package/src/services/vectorstore/mock.ts +403 -0
- package/src/services/vectorstore/pgvector.ts +695 -0
- package/src/services/vectorstore/types.ts +247 -0
- package/src/startup.ts +389 -0
- package/src/types/api.types.ts +193 -0
- package/src/types/document.types.ts +103 -0
- package/src/types/index.ts +241 -0
- package/src/types/profile.base.ts +133 -0
- package/src/utils/errors.ts +447 -0
- package/src/utils/id.ts +15 -0
- package/src/utils/index.ts +101 -0
- package/src/utils/logger.ts +313 -0
- package/src/utils/sanitization.ts +501 -0
- package/src/utils/secret-validation.ts +273 -0
- package/src/utils/synonyms.ts +188 -0
- package/src/utils/validation.ts +581 -0
- package/src/workers/chunking.worker.ts +242 -0
- package/src/workers/embedding.worker.ts +358 -0
- package/src/workers/extraction.worker.ts +346 -0
- package/src/workers/indexing.worker.ts +505 -0
- 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
|
package/src/api/index.ts
ADDED
|
@@ -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
|
+
}
|