feat(hardening): M-Hard 6 + 7 — PostgreSQL backend + production server infra
M-Hard 6: PostgreSQL Storage Backend - @shade/storage-postgres with PostgresStorage + PostgresPrekeyStore - Drizzle-style raw SQL ensureClientTables / ensurePrekeyServerTables - All tables prefixed `shade_` to avoid collisions in shared databases - DELETE ... FOR UPDATE SKIP LOCKED for concurrent OTPK consumption - Tests skip gracefully without SHADE_TEST_PG_URL, run against real PG when set M-Hard 7: Production Server Infrastructure - Structured JSON logger (logger.ts) — SHADE_LOG_LEVEL configurable - Health endpoints (/health, /healthz, /ready) — Kubernetes-friendly - Prometheus metrics (/metrics) — counters, histograms, middleware - Graceful shutdown with SIGTERM/SIGINT handlers + store close - Production Dockerfile with non-root user, healthcheck, multi-stage build - docker-compose.yml example for Dokploy with optional PostgreSQL 193 tests passing, 0 failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
packages/shade-server/.dockerignore
Normal file
7
packages/shade-server/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
*.tsbuildinfo
|
||||
.git
|
||||
.DS_Store
|
||||
**/tests
|
||||
**/*.test.ts
|
||||
@@ -1,11 +1,51 @@
|
||||
# ─── Build stage ────────────────────────────────────────────
|
||||
FROM oven/bun:1 AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy workspace root
|
||||
COPY package.json bun.lock ./
|
||||
COPY tsconfig.json ./
|
||||
|
||||
# Copy all packages we depend on
|
||||
COPY packages/shade-core ./packages/shade-core
|
||||
COPY packages/shade-crypto-web ./packages/shade-crypto-web
|
||||
COPY packages/shade-server ./packages/shade-server
|
||||
COPY packages/shade-storage-sqlite ./packages/shade-storage-sqlite
|
||||
COPY packages/shade-storage-postgres ./packages/shade-storage-postgres
|
||||
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# ─── Production stage ───────────────────────────────────────
|
||||
FROM oven/bun:1-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --production --frozen-lockfile
|
||||
LABEL org.opencontainers.image.title="Shade Prekey Server"
|
||||
LABEL org.opencontainers.image.description="E2EE prekey distribution server (Signal Protocol)"
|
||||
LABEL org.opencontainers.image.source="https://github.com/Sterister/Shade"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
COPY src/ src/
|
||||
# Install curl for healthcheck
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
# Non-root user
|
||||
RUN addgroup -S shade && adduser -S shade -G shade
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder --chown=shade:shade /build /app
|
||||
|
||||
# Persistent data directory
|
||||
RUN mkdir -p /data && chown shade:shade /data
|
||||
VOLUME ["/data"]
|
||||
|
||||
USER shade
|
||||
|
||||
EXPOSE 3900
|
||||
|
||||
CMD ["bun", "run", "src/standalone.ts"]
|
||||
# Default to SQLite on the persistent volume
|
||||
ENV SHADE_PREKEY_DB_PATH=/data/shade-prekeys.db
|
||||
ENV PORT=3900
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -fsS http://localhost:${PORT}/health || exit 1
|
||||
|
||||
CMD ["bun", "run", "packages/shade-server/src/standalone.ts"]
|
||||
|
||||
50
packages/shade-server/docker-compose.yml
Normal file
50
packages/shade-server/docker-compose.yml
Normal file
@@ -0,0 +1,50 @@
|
||||
# Shade Prekey Server — Dokploy/Docker Compose deployment
|
||||
#
|
||||
# Usage:
|
||||
# docker compose up -d
|
||||
#
|
||||
# The SQLite database is persisted to a named volume so data survives
|
||||
# container restarts. To use PostgreSQL instead, set SHADE_PREKEY_PG_URL
|
||||
# and uncomment the depends_on / postgres service block below.
|
||||
|
||||
services:
|
||||
shade-prekey:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: packages/shade-server/Dockerfile
|
||||
image: shade-prekey-server:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3900:3900"
|
||||
volumes:
|
||||
- shade-data:/data
|
||||
environment:
|
||||
- PORT=3900
|
||||
- SHADE_PREKEY_DB_PATH=/data/shade-prekeys.db
|
||||
- SHADE_LOG_LEVEL=info
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:3900/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 3
|
||||
# Uncomment for PostgreSQL backend:
|
||||
# depends_on:
|
||||
# - postgres
|
||||
# environment:
|
||||
# - SHADE_PREKEY_PG_URL=postgres://shade:shade@postgres:5432/shade
|
||||
|
||||
# Uncomment to use PostgreSQL instead of SQLite:
|
||||
# postgres:
|
||||
# image: postgres:16-alpine
|
||||
# restart: unless-stopped
|
||||
# environment:
|
||||
# - POSTGRES_USER=shade
|
||||
# - POSTGRES_PASSWORD=shade
|
||||
# - POSTGRES_DB=shade
|
||||
# volumes:
|
||||
# - shade-pg-data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
shade-data:
|
||||
# shade-pg-data:
|
||||
55
packages/shade-server/src/health.ts
Normal file
55
packages/shade-server/src/health.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { PrekeyStore } from './store.js';
|
||||
|
||||
/**
|
||||
* Health check endpoint for production deployments.
|
||||
*
|
||||
* GET /health → 200 if storage backend reachable, 503 otherwise
|
||||
*/
|
||||
export function createHealthRoutes(store: PrekeyStore, version: string): Hono {
|
||||
const startTime = Date.now();
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get('/health', async (c) => {
|
||||
try {
|
||||
// Probe the store with a no-op operation
|
||||
await store.getOneTimePreKeyCount('__health__');
|
||||
return c.json({
|
||||
status: 'ok',
|
||||
version,
|
||||
uptimeSeconds: Math.floor((Date.now() - startTime) / 1000),
|
||||
});
|
||||
} catch (err) {
|
||||
return c.json(
|
||||
{
|
||||
status: 'error',
|
||||
version,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
503,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Kubernetes-style probes
|
||||
app.get('/healthz', async (c) => {
|
||||
try {
|
||||
await store.getOneTimePreKeyCount('__health__');
|
||||
return c.text('ok');
|
||||
} catch {
|
||||
return c.text('error', 503);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/ready', async (c) => {
|
||||
try {
|
||||
await store.getOneTimePreKeyCount('__health__');
|
||||
return c.text('ready');
|
||||
} catch {
|
||||
return c.text('not ready', 503);
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
37
packages/shade-server/src/logger.ts
Normal file
37
packages/shade-server/src/logger.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Minimal structured JSON logger.
|
||||
*
|
||||
* Outputs one line per log call: { ts, level, msg, ...fields }
|
||||
* Use SHADE_LOG_LEVEL env var to filter (debug, info, warn, error).
|
||||
*/
|
||||
|
||||
type Level = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
const LEVEL_PRIORITY: Record<Level, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
const minLevel: Level = (process.env.SHADE_LOG_LEVEL as Level) ?? 'info';
|
||||
|
||||
function log(level: Level, msg: string, fields?: Record<string, unknown>): void {
|
||||
if (LEVEL_PRIORITY[level] < LEVEL_PRIORITY[minLevel]) return;
|
||||
const entry = {
|
||||
ts: new Date().toISOString(),
|
||||
level,
|
||||
msg,
|
||||
...fields,
|
||||
};
|
||||
// Log to stderr so request bodies on stdout don't interfere
|
||||
const stream = level === 'error' || level === 'warn' ? process.stderr : process.stdout;
|
||||
stream.write(JSON.stringify(entry) + '\n');
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug: (msg: string, fields?: Record<string, unknown>) => log('debug', msg, fields),
|
||||
info: (msg: string, fields?: Record<string, unknown>) => log('info', msg, fields),
|
||||
warn: (msg: string, fields?: Record<string, unknown>) => log('warn', msg, fields),
|
||||
error: (msg: string, fields?: Record<string, unknown>) => log('error', msg, fields),
|
||||
};
|
||||
103
packages/shade-server/src/metrics.ts
Normal file
103
packages/shade-server/src/metrics.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Hono } from 'hono';
|
||||
|
||||
/**
|
||||
* Prometheus-style metrics for the Shade Prekey Server.
|
||||
*
|
||||
* Exports counters for each route + a histogram of request durations.
|
||||
* Format: text/plain Prometheus exposition format.
|
||||
*/
|
||||
|
||||
interface CounterMap {
|
||||
[labels: string]: number;
|
||||
}
|
||||
|
||||
class Metrics {
|
||||
// Counters
|
||||
private requests: CounterMap = {};
|
||||
private errors: CounterMap = {};
|
||||
|
||||
// Histogram buckets (milliseconds)
|
||||
private readonly latencyBuckets = [1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000];
|
||||
private latencyCounts: Record<string, number[]> = {};
|
||||
private latencySum: CounterMap = {};
|
||||
private latencyCount: CounterMap = {};
|
||||
|
||||
recordRequest(route: string, status: number, durationMs: number): void {
|
||||
const key = `${route}|${status}`;
|
||||
this.requests[key] = (this.requests[key] ?? 0) + 1;
|
||||
|
||||
if (status >= 400) {
|
||||
this.errors[key] = (this.errors[key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
if (!this.latencyCounts[route]) {
|
||||
this.latencyCounts[route] = new Array(this.latencyBuckets.length).fill(0);
|
||||
this.latencySum[route] = 0;
|
||||
this.latencyCount[route] = 0;
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.latencyBuckets.length; i++) {
|
||||
if (durationMs <= this.latencyBuckets[i]!) {
|
||||
this.latencyCounts[route]![i]!++;
|
||||
}
|
||||
}
|
||||
this.latencySum[route] = (this.latencySum[route] ?? 0) + durationMs;
|
||||
this.latencyCount[route] = (this.latencyCount[route] ?? 0) + 1;
|
||||
}
|
||||
|
||||
/** Render in Prometheus exposition format */
|
||||
render(): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('# HELP shade_requests_total Total HTTP requests');
|
||||
lines.push('# TYPE shade_requests_total counter');
|
||||
for (const [key, value] of Object.entries(this.requests)) {
|
||||
const [route, status] = key.split('|');
|
||||
lines.push(`shade_requests_total{route="${route}",status="${status}"} ${value}`);
|
||||
}
|
||||
|
||||
lines.push('# HELP shade_errors_total Total HTTP errors (4xx/5xx)');
|
||||
lines.push('# TYPE shade_errors_total counter');
|
||||
for (const [key, value] of Object.entries(this.errors)) {
|
||||
const [route, status] = key.split('|');
|
||||
lines.push(`shade_errors_total{route="${route}",status="${status}"} ${value}`);
|
||||
}
|
||||
|
||||
lines.push('# HELP shade_request_duration_ms Request duration in milliseconds');
|
||||
lines.push('# TYPE shade_request_duration_ms histogram');
|
||||
for (const route of Object.keys(this.latencyCounts)) {
|
||||
const counts = this.latencyCounts[route]!;
|
||||
for (let i = 0; i < this.latencyBuckets.length; i++) {
|
||||
lines.push(`shade_request_duration_ms_bucket{route="${route}",le="${this.latencyBuckets[i]}"} ${counts[i]}`);
|
||||
}
|
||||
lines.push(`shade_request_duration_ms_bucket{route="${route}",le="+Inf"} ${this.latencyCount[route]}`);
|
||||
lines.push(`shade_request_duration_ms_sum{route="${route}"} ${this.latencySum[route]}`);
|
||||
lines.push(`shade_request_duration_ms_count{route="${route}"} ${this.latencyCount[route]}`);
|
||||
}
|
||||
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
}
|
||||
|
||||
export const metrics = new Metrics();
|
||||
|
||||
/** Hono middleware that records request metrics */
|
||||
export function metricsMiddleware() {
|
||||
return async (c: any, next: any) => {
|
||||
const start = Date.now();
|
||||
await next();
|
||||
const duration = Date.now() - start;
|
||||
const route = c.req.routePath ?? c.req.path;
|
||||
metrics.recordRequest(route, c.res.status, duration);
|
||||
};
|
||||
}
|
||||
|
||||
/** Mount the /metrics endpoint */
|
||||
export function createMetricsRoutes(): Hono {
|
||||
const app = new Hono();
|
||||
app.get('/metrics', (c) => {
|
||||
c.header('Content-Type', 'text/plain; version=0.0.4');
|
||||
return c.body(metrics.render());
|
||||
});
|
||||
return app;
|
||||
}
|
||||
@@ -1,24 +1,82 @@
|
||||
import { Hono } from 'hono';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import { createPrekeyRoutes } from './routes.js';
|
||||
import { createHealthRoutes } from './health.js';
|
||||
import { createMetricsRoutes, metricsMiddleware } from './metrics.js';
|
||||
import { logger } from './logger.js';
|
||||
import type { PrekeyStore } from './store.js';
|
||||
|
||||
async function createStore(): Promise<PrekeyStore> {
|
||||
const dbPath = process.env.SHADE_PREKEY_DB_PATH;
|
||||
if (dbPath) {
|
||||
const { SqlitePrekeyStore } = await import('@shade/storage-sqlite');
|
||||
console.log(`[Shade] Using SQLite prekey store: ${dbPath}`);
|
||||
return new SqlitePrekeyStore(dbPath);
|
||||
const VERSION = '1.0.0';
|
||||
|
||||
async function createStore(): Promise<PrekeyStore & { close?: () => void | Promise<void> }> {
|
||||
const sqlitePath = process.env.SHADE_PREKEY_DB_PATH;
|
||||
const pgUrl = process.env.SHADE_PREKEY_PG_URL;
|
||||
|
||||
if (pgUrl) {
|
||||
const { PostgresPrekeyStore } = await import('@shade/storage-postgres');
|
||||
logger.info('Using PostgreSQL prekey store', { url: maskUrl(pgUrl) });
|
||||
return PostgresPrekeyStore.create(pgUrl);
|
||||
}
|
||||
|
||||
if (sqlitePath) {
|
||||
const { SqlitePrekeyStore } = await import('@shade/storage-sqlite');
|
||||
logger.info('Using SQLite prekey store', { path: sqlitePath });
|
||||
return new SqlitePrekeyStore(sqlitePath);
|
||||
}
|
||||
|
||||
const { MemoryPrekeyStore } = await import('./memory-store.js');
|
||||
console.log('[Shade] Using in-memory prekey store (data will not persist)');
|
||||
logger.warn('Using in-memory prekey store — data will not persist across restarts');
|
||||
return new MemoryPrekeyStore();
|
||||
}
|
||||
|
||||
function maskUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.password) u.password = '***';
|
||||
return u.toString();
|
||||
} catch {
|
||||
return '***';
|
||||
}
|
||||
}
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
const store = await createStore();
|
||||
const server = createPrekeyRoutes(store, crypto);
|
||||
|
||||
// Compose the full app: metrics middleware + health + metrics + prekey routes
|
||||
const app = new Hono();
|
||||
app.use('*', metricsMiddleware());
|
||||
app.route('/', createHealthRoutes(store, VERSION));
|
||||
app.route('/', createMetricsRoutes());
|
||||
app.route('/', createPrekeyRoutes(store, crypto));
|
||||
|
||||
const port = Number(process.env.PORT ?? 3900);
|
||||
|
||||
export default { port, fetch: server.fetch };
|
||||
logger.info('Shade Prekey Server starting', { port, version: VERSION });
|
||||
|
||||
console.log(`Shade Prekey Server listening on port ${port}`);
|
||||
const server = Bun.serve({ port, fetch: app.fetch });
|
||||
|
||||
// ─── Graceful shutdown ───────────────────────────────────────
|
||||
|
||||
let shuttingDown = false;
|
||||
async function shutdown(signal: string) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
logger.info('Shutting down', { signal });
|
||||
|
||||
try {
|
||||
server.stop();
|
||||
if ('close' in store && typeof store.close === 'function') {
|
||||
await store.close();
|
||||
}
|
||||
logger.info('Shutdown complete');
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
logger.error('Error during shutdown', { error: String(err) });
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
logger.info('Shade Prekey Server listening', { port });
|
||||
|
||||
66
packages/shade-server/tests/health-metrics.test.ts
Normal file
66
packages/shade-server/tests/health-metrics.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { Hono } from 'hono';
|
||||
import { createHealthRoutes } from '../src/health.js';
|
||||
import { createMetricsRoutes, metricsMiddleware, metrics } from '../src/metrics.js';
|
||||
import { MemoryPrekeyStore } from '../src/memory-store.js';
|
||||
|
||||
describe('Health endpoints', () => {
|
||||
test('GET /health returns ok with version and uptime', async () => {
|
||||
const store = new MemoryPrekeyStore();
|
||||
const app = createHealthRoutes(store, '1.2.3');
|
||||
|
||||
const res = await app.request('/health');
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.status).toBe('ok');
|
||||
expect(body.version).toBe('1.2.3');
|
||||
expect(body.uptimeSeconds).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('GET /healthz returns text "ok"', async () => {
|
||||
const store = new MemoryPrekeyStore();
|
||||
const app = createHealthRoutes(store, '1.0.0');
|
||||
const res = await app.request('/healthz');
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe('ok');
|
||||
});
|
||||
|
||||
test('GET /ready returns text "ready"', async () => {
|
||||
const store = new MemoryPrekeyStore();
|
||||
const app = createHealthRoutes(store, '1.0.0');
|
||||
const res = await app.request('/ready');
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe('ready');
|
||||
});
|
||||
|
||||
test('returns 503 if store throws', async () => {
|
||||
const broken = {
|
||||
getOneTimePreKeyCount: async () => { throw new Error('db down'); },
|
||||
} as any;
|
||||
const app = createHealthRoutes(broken, '1.0.0');
|
||||
const res = await app.request('/health');
|
||||
expect(res.status).toBe(503);
|
||||
const body = await res.json();
|
||||
expect(body.status).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Metrics endpoint', () => {
|
||||
test('returns Prometheus exposition format', async () => {
|
||||
// Trigger some metrics
|
||||
const app = new Hono();
|
||||
app.use('*', metricsMiddleware());
|
||||
app.get('/test', (c) => c.text('hello'));
|
||||
app.route('/', createMetricsRoutes());
|
||||
|
||||
await app.request('/test');
|
||||
await app.request('/test');
|
||||
|
||||
const res = await app.request('/metrics');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toMatch(/text\/plain/);
|
||||
const body = await res.text();
|
||||
expect(body).toContain('shade_requests_total');
|
||||
expect(body).toContain('shade_request_duration_ms');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user