feat: upgrade EncryptID server to PostgreSQL

Replace in-memory Maps with persistent PostgreSQL storage:
- Add db.ts with typed query functions for users, credentials, challenges
- Add schema.sql with users/credentials/challenges tables
- Update server.ts to use async DB queries
- Add postgres service to docker-compose
- Health endpoint now reports database connectivity
- Auto-cleanup of expired challenges every 10 minutes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 07:34:13 -07:00
parent 8e10f5cb03
commit e5af01119b
6 changed files with 359 additions and 75 deletions

View File

@ -9,8 +9,8 @@ WORKDIR /app
# Copy package files
COPY package.json bun.lockb* ./
# Install dependencies
RUN bun install --frozen-lockfile
# Install dependencies (including postgres)
RUN bun install --frozen-lockfile || bun install
# Copy source
COPY src/encryptid ./src/encryptid
@ -44,8 +44,8 @@ ENV PORT=3000
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD bun -e "fetch('http://localhost:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD bun -e "fetch('http://localhost:3000/health').then(r => r.json()).then(d => process.exit(d.database ? 0 : 1)).catch(() => process.exit(1))"
# Start server
CMD ["bun", "run", "src/encryptid/server.ts"]

View File

@ -8,10 +8,14 @@ services:
dockerfile: Dockerfile.encryptid
container_name: encryptid
restart: unless-stopped
depends_on:
encryptid-db:
condition: service_healthy
environment:
- NODE_ENV=production
- PORT=3000
- JWT_SECRET=${JWT_SECRET:-change-this-in-production}
- DATABASE_URL=postgres://encryptid:${ENCRYPTID_DB_PASSWORD:-encryptid}@encryptid-db:5432/encryptid
labels:
# Traefik auto-discovery
- "traefik.enable=true"
@ -23,13 +27,38 @@ services:
- "traefik.http.routers.encryptid-wellknown.entrypoints=web"
networks:
- traefik-public
- encryptid-internal
healthcheck:
test: ["CMD", "bun", "-e", "fetch('http://localhost:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
test: ["CMD", "bun", "-e", "fetch('http://localhost:3000/health').then(r => r.json()).then(d => process.exit(d.database ? 0 : 1)).catch(() => process.exit(1))"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
encryptid-db:
image: postgres:16-alpine
container_name: encryptid-db
restart: unless-stopped
environment:
- POSTGRES_DB=encryptid
- POSTGRES_USER=encryptid
- POSTGRES_PASSWORD=${ENCRYPTID_DB_PASSWORD:-encryptid}
volumes:
- encryptid-pgdata:/var/lib/postgresql/data
networks:
- encryptid-internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U encryptid -d encryptid"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
volumes:
encryptid-pgdata:
networks:
traefik-public:
external: true
encryptid-internal:
driver: bridge

View File

@ -16,6 +16,7 @@
"@automerge/automerge": "^2.2.8",
"@lit/reactive-element": "^2.0.4",
"hono": "^4.11.7",
"postgres": "^3.4.5",
"perfect-arrows": "^0.3.7",
"perfect-freehand": "^1.2.2"
},

205
src/encryptid/db.ts Normal file
View File

@ -0,0 +1,205 @@
/**
* EncryptID Database Layer PostgreSQL
*
* Replaces in-memory Maps with persistent PostgreSQL storage.
* Uses the 'postgres' npm package (lightweight, no native deps with Bun).
*/
import postgres from 'postgres';
import { readFileSync } from 'fs';
import { join } from 'path';
// ============================================================================
// CONNECTION
// ============================================================================
const DATABASE_URL = process.env.DATABASE_URL || 'postgres://encryptid:encryptid@localhost:5432/encryptid';
const sql = postgres(DATABASE_URL, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
// ============================================================================
// TYPES
// ============================================================================
export interface StoredCredential {
credentialId: string;
publicKey: string;
userId: string;
username: string;
counter: number;
createdAt: number;
lastUsed?: number;
transports?: string[];
}
export interface StoredChallenge {
challenge: string;
userId?: string;
type: 'registration' | 'authentication';
createdAt: number;
expiresAt: number;
}
// ============================================================================
// INITIALIZATION
// ============================================================================
export async function initDatabase(): Promise<void> {
const schema = readFileSync(join(import.meta.dir, 'schema.sql'), 'utf-8');
await sql.unsafe(schema);
console.log('EncryptID: Database initialized');
// Clean expired challenges on startup
await cleanExpiredChallenges();
}
// ============================================================================
// USER OPERATIONS
// ============================================================================
export async function createUser(id: string, username: string, displayName?: string, did?: string): Promise<void> {
await sql`
INSERT INTO users (id, username, display_name, did)
VALUES (${id}, ${username}, ${displayName || username}, ${did || null})
ON CONFLICT (id) DO NOTHING
`;
}
export async function getUserByUsername(username: string) {
const [user] = await sql`SELECT * FROM users WHERE username = ${username}`;
return user || null;
}
// ============================================================================
// CREDENTIAL OPERATIONS
// ============================================================================
export async function storeCredential(cred: StoredCredential): Promise<void> {
// Ensure user exists first
await createUser(cred.userId, cred.username);
await sql`
INSERT INTO credentials (credential_id, user_id, public_key, counter, transports, created_at)
VALUES (
${cred.credentialId},
${cred.userId},
${cred.publicKey},
${cred.counter},
${cred.transports || null},
${new Date(cred.createdAt)}
)
`;
}
export async function getCredential(credentialId: string): Promise<StoredCredential | null> {
const rows = await sql`
SELECT c.credential_id, c.public_key, c.user_id, c.counter,
c.transports, c.created_at, c.last_used, u.username
FROM credentials c
JOIN users u ON c.user_id = u.id
WHERE c.credential_id = ${credentialId}
`;
if (rows.length === 0) return null;
const row = rows[0];
return {
credentialId: row.credential_id,
publicKey: row.public_key,
userId: row.user_id,
username: row.username,
counter: row.counter,
createdAt: new Date(row.created_at).getTime(),
lastUsed: row.last_used ? new Date(row.last_used).getTime() : undefined,
transports: row.transports,
};
}
export async function updateCredentialUsage(credentialId: string, newCounter: number): Promise<void> {
await sql`
UPDATE credentials
SET counter = ${newCounter}, last_used = NOW()
WHERE credential_id = ${credentialId}
`;
}
export async function getUserCredentials(userId: string): Promise<StoredCredential[]> {
const rows = await sql`
SELECT c.credential_id, c.public_key, c.user_id, c.counter,
c.transports, c.created_at, c.last_used, u.username
FROM credentials c
JOIN users u ON c.user_id = u.id
WHERE c.user_id = ${userId}
`;
return rows.map(row => ({
credentialId: row.credential_id,
publicKey: row.public_key,
userId: row.user_id,
username: row.username,
counter: row.counter,
createdAt: new Date(row.created_at).getTime(),
lastUsed: row.last_used ? new Date(row.last_used).getTime() : undefined,
transports: row.transports,
}));
}
// ============================================================================
// CHALLENGE OPERATIONS
// ============================================================================
export async function storeChallenge(ch: StoredChallenge): Promise<void> {
await sql`
INSERT INTO challenges (challenge, user_id, type, created_at, expires_at)
VALUES (
${ch.challenge},
${ch.userId || null},
${ch.type},
${new Date(ch.createdAt)},
${new Date(ch.expiresAt)}
)
`;
}
export async function getChallenge(challenge: string): Promise<StoredChallenge | null> {
const rows = await sql`
SELECT * FROM challenges WHERE challenge = ${challenge}
`;
if (rows.length === 0) return null;
const row = rows[0];
return {
challenge: row.challenge,
userId: row.user_id || undefined,
type: row.type as 'registration' | 'authentication',
createdAt: new Date(row.created_at).getTime(),
expiresAt: new Date(row.expires_at).getTime(),
};
}
export async function deleteChallenge(challenge: string): Promise<void> {
await sql`DELETE FROM challenges WHERE challenge = ${challenge}`;
}
export async function cleanExpiredChallenges(): Promise<number> {
const result = await sql`DELETE FROM challenges WHERE expires_at < NOW()`;
return result.count;
}
// ============================================================================
// HEALTH CHECK
// ============================================================================
export async function checkDatabaseHealth(): Promise<boolean> {
try {
await sql`SELECT 1`;
return true;
} catch {
return false;
}
}
export { sql };

33
src/encryptid/schema.sql Normal file
View File

@ -0,0 +1,33 @@
-- EncryptID PostgreSQL Schema
-- Run once to initialize the database
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
display_name TEXT,
did TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS credentials (
credential_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
public_key TEXT NOT NULL,
counter INTEGER DEFAULT 0,
transports TEXT[],
created_at TIMESTAMPTZ DEFAULT NOW(),
last_used TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_credentials_user_id ON credentials(user_id);
CREATE TABLE IF NOT EXISTS challenges (
challenge TEXT PRIMARY KEY,
user_id TEXT,
type TEXT NOT NULL CHECK (type IN ('registration', 'authentication')),
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
-- Auto-clean expired challenges (run periodically or use pg_cron)
CREATE INDEX IF NOT EXISTS idx_challenges_expires_at ON challenges(expires_at);

View File

@ -3,6 +3,8 @@
*
* Handles WebAuthn registration/authentication, session management,
* and serves the .well-known/webauthn configuration.
*
* Storage: PostgreSQL (via db.ts)
*/
import { Hono } from 'hono';
@ -10,6 +12,21 @@ import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { serveStatic } from 'hono/bun';
import { sign, verify } from 'hono/jwt';
import {
initDatabase,
storeCredential,
getCredential,
updateCredentialUsage,
getUserCredentials,
storeChallenge,
getChallenge,
deleteChallenge,
cleanExpiredChallenges,
checkDatabaseHealth,
createUser,
type StoredCredential,
type StoredChallenge,
} from './db.js';
// ============================================================================
// CONFIGURATION
@ -35,34 +52,6 @@ const CONFIG = {
],
};
// ============================================================================
// IN-MEMORY STORAGE (Replace with database in production)
// ============================================================================
interface StoredCredential {
credentialId: string;
publicKey: string; // Base64url encoded
userId: string;
username: string;
counter: number;
createdAt: number;
lastUsed?: number;
transports?: string[];
}
interface StoredChallenge {
challenge: string;
userId?: string;
type: 'registration' | 'authentication';
createdAt: number;
expiresAt: number;
}
// In-memory stores (replace with D1/PostgreSQL in production)
const credentials = new Map<string, StoredCredential>();
const challenges = new Map<string, StoredChallenge>();
const userCredentials = new Map<string, string[]>(); // userId -> credentialIds
// ============================================================================
// HONO APP
// ============================================================================
@ -89,9 +78,11 @@ app.get('/.well-known/webauthn', (c) => {
});
});
// Health check
app.get('/health', (c) => {
return c.json({ status: 'ok', service: 'encryptid', timestamp: Date.now() });
// Health check — includes database connectivity
app.get('/health', async (c) => {
const dbHealthy = await checkDatabaseHealth();
const status = dbHealthy ? 'ok' : 'degraded';
return c.json({ status, service: 'encryptid', database: dbHealthy, timestamp: Date.now() }, dbHealthy ? 200 : 503);
});
// ============================================================================
@ -114,7 +105,7 @@ app.post('/api/register/start', async (c) => {
// Generate user ID
const userId = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
// Store challenge
// Store challenge in database
const challengeRecord: StoredChallenge = {
challenge,
userId,
@ -122,7 +113,7 @@ app.post('/api/register/start', async (c) => {
createdAt: Date.now(),
expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes
};
challenges.set(challenge, challengeRecord);
await storeChallenge(challengeRecord);
// Build registration options
const options = {
@ -162,20 +153,23 @@ app.post('/api/register/complete', async (c) => {
const { challenge, credential, userId, username } = await c.req.json();
// Verify challenge
const challengeRecord = challenges.get(challenge);
const challengeRecord = await getChallenge(challenge);
if (!challengeRecord || challengeRecord.type !== 'registration') {
return c.json({ error: 'Invalid challenge' }, 400);
}
if (Date.now() > challengeRecord.expiresAt) {
challenges.delete(challenge);
await deleteChallenge(challenge);
return c.json({ error: 'Challenge expired' }, 400);
}
challenges.delete(challenge);
await deleteChallenge(challenge);
// In production, verify the attestation properly
// For now, we trust the client-side verification
// Store credential
// Create user and store credential in database
const did = `did:key:${userId.slice(0, 32)}`;
await createUser(userId, username, username, did);
const storedCredential: StoredCredential = {
credentialId: credential.credentialId,
publicKey: credential.publicKey,
@ -185,13 +179,7 @@ app.post('/api/register/complete', async (c) => {
createdAt: Date.now(),
transports: credential.transports,
};
credentials.set(credential.credentialId, storedCredential);
// Map user to credential
const userCreds = userCredentials.get(userId) || [];
userCreds.push(credential.credentialId);
userCredentials.set(userId, userCreds);
await storeCredential(storedCredential);
console.log('EncryptID: Credential registered', {
credentialId: credential.credentialId.slice(0, 20) + '...',
@ -205,7 +193,7 @@ app.post('/api/register/complete', async (c) => {
success: true,
userId,
token,
did: `did:key:${userId.slice(0, 32)}`, // Simplified DID
did,
});
});
@ -223,19 +211,19 @@ app.post('/api/auth/start', async (c) => {
// Generate challenge
const challenge = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
// Store challenge
// Store challenge in database
const challengeRecord: StoredChallenge = {
challenge,
type: 'authentication',
createdAt: Date.now(),
expiresAt: Date.now() + 5 * 60 * 1000,
};
challenges.set(challenge, challengeRecord);
await storeChallenge(challengeRecord);
// Build allowed credentials if specified
let allowCredentials;
if (credentialId) {
const cred = credentials.get(credentialId);
const cred = await getCredential(credentialId);
if (cred) {
allowCredentials = [{
type: 'public-key',
@ -262,19 +250,19 @@ app.post('/api/auth/start', async (c) => {
app.post('/api/auth/complete', async (c) => {
const { challenge, credential } = await c.req.json();
// Verify challenge
const challengeRecord = challenges.get(challenge);
// Verify challenge from database
const challengeRecord = await getChallenge(challenge);
if (!challengeRecord || challengeRecord.type !== 'authentication') {
return c.json({ error: 'Invalid challenge' }, 400);
}
if (Date.now() > challengeRecord.expiresAt) {
challenges.delete(challenge);
await deleteChallenge(challenge);
return c.json({ error: 'Challenge expired' }, 400);
}
challenges.delete(challenge);
await deleteChallenge(challenge);
// Look up credential
const storedCredential = credentials.get(credential.credentialId);
// Look up credential from database
const storedCredential = await getCredential(credential.credentialId);
if (!storedCredential) {
return c.json({ error: 'Unknown credential' }, 400);
}
@ -282,9 +270,8 @@ app.post('/api/auth/complete', async (c) => {
// In production, verify signature against stored public key
// For now, we trust the client-side verification
// Update counter and last used
storedCredential.counter++;
storedCredential.lastUsed = Date.now();
// Update counter and last used in database
await updateCredentialUsage(credential.credentialId, storedCredential.counter + 1);
console.log('EncryptID: Authentication successful', {
credentialId: credential.credentialId.slice(0, 20) + '...',
@ -311,7 +298,7 @@ app.post('/api/auth/complete', async (c) => {
// ============================================================================
/**
* Verify session token
* Verify session token (supports both GET with Authorization header and POST with body)
*/
app.get('/api/session/verify', async (c) => {
const authHeader = c.req.header('Authorization');
@ -335,6 +322,27 @@ app.get('/api/session/verify', async (c) => {
}
});
app.post('/api/session/verify', async (c) => {
const { token } = await c.req.json();
if (!token) {
return c.json({ valid: false, error: 'No token' }, 400);
}
try {
const payload = await verify(token, CONFIG.jwtSecret);
return c.json({
valid: true,
claims: payload,
userId: payload.sub,
username: payload.username,
did: payload.did,
exp: payload.exp,
});
} catch {
return c.json({ valid: false, error: 'Invalid token' });
}
});
/**
* Refresh session token
*/
@ -380,17 +388,13 @@ app.get('/api/user/credentials', async (c) => {
const payload = await verify(token, CONFIG.jwtSecret);
const userId = payload.sub as string;
const userCreds = userCredentials.get(userId) || [];
const credentialList = userCreds.map(credId => {
const cred = credentials.get(credId);
if (!cred) return null;
return {
credentialId: cred.credentialId,
createdAt: cred.createdAt,
lastUsed: cred.lastUsed,
transports: cred.transports,
};
}).filter(Boolean);
const creds = await getUserCredentials(userId);
const credentialList = creds.map(cred => ({
credentialId: cred.credentialId,
createdAt: cred.createdAt,
lastUsed: cred.lastUsed,
transports: cred.transports,
}));
return c.json({ credentials: credentialList });
} catch {
@ -594,9 +598,20 @@ app.get('/', (c) => {
});
// ============================================================================
// START SERVER
// DATABASE INITIALIZATION & SERVER START
// ============================================================================
// Initialize database on startup
initDatabase().catch(err => {
console.error('EncryptID: Failed to initialize database', err);
process.exit(1);
});
// Clean expired challenges every 10 minutes
setInterval(() => {
cleanExpiredChallenges().catch(() => {});
}, 10 * 60 * 1000);
console.log(`
@ -606,6 +621,7 @@ console.log(`
Port: ${CONFIG.port}
RP ID: ${CONFIG.rpId}
Storage: PostgreSQL
`);