diff --git a/bun.lock b/bun.lock index e467b77..63cbc61 100644 --- a/bun.lock +++ b/bun.lock @@ -46,6 +46,19 @@ "@shade/crypto-web": "workspace:*", }, }, + "packages/shade-storage-postgres": { + "name": "@shade/storage-postgres", + "version": "0.1.0", + "dependencies": { + "@shade/core": "workspace:*", + "@shade/server": "workspace:*", + "drizzle-orm": "^0.45.2", + "postgres": "^3.4.9", + }, + "devDependencies": { + "@shade/crypto-web": "workspace:*", + }, + }, "packages/shade-storage-sqlite": { "name": "@shade/storage-sqlite", "version": "0.1.0", @@ -79,6 +92,8 @@ "@shade/server": ["@shade/server@workspace:packages/shade-server"], + "@shade/storage-postgres": ["@shade/storage-postgres@workspace:packages/shade-storage-postgres"], + "@shade/storage-sqlite": ["@shade/storage-sqlite@workspace:packages/shade-storage-sqlite"], "@shade/transport": ["@shade/transport@workspace:packages/shade-transport"], @@ -87,8 +102,12 @@ "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], + "hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="], + "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], } } diff --git a/packages/shade-server/.dockerignore b/packages/shade-server/.dockerignore new file mode 100644 index 0000000..a023c04 --- /dev/null +++ b/packages/shade-server/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +*.tsbuildinfo +.git +.DS_Store +**/tests +**/*.test.ts diff --git a/packages/shade-server/Dockerfile b/packages/shade-server/Dockerfile index 097289a..8bb8aac 100644 --- a/packages/shade-server/Dockerfile +++ b/packages/shade-server/Dockerfile @@ -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"] diff --git a/packages/shade-server/docker-compose.yml b/packages/shade-server/docker-compose.yml new file mode 100644 index 0000000..ef9f333 --- /dev/null +++ b/packages/shade-server/docker-compose.yml @@ -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: diff --git a/packages/shade-server/src/health.ts b/packages/shade-server/src/health.ts new file mode 100644 index 0000000..74a674b --- /dev/null +++ b/packages/shade-server/src/health.ts @@ -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; +} diff --git a/packages/shade-server/src/logger.ts b/packages/shade-server/src/logger.ts new file mode 100644 index 0000000..7dbc0f0 --- /dev/null +++ b/packages/shade-server/src/logger.ts @@ -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 = { + 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): 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) => log('debug', msg, fields), + info: (msg: string, fields?: Record) => log('info', msg, fields), + warn: (msg: string, fields?: Record) => log('warn', msg, fields), + error: (msg: string, fields?: Record) => log('error', msg, fields), +}; diff --git a/packages/shade-server/src/metrics.ts b/packages/shade-server/src/metrics.ts new file mode 100644 index 0000000..6015a1a --- /dev/null +++ b/packages/shade-server/src/metrics.ts @@ -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 = {}; + 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; +} diff --git a/packages/shade-server/src/standalone.ts b/packages/shade-server/src/standalone.ts index f153203..6571d95 100644 --- a/packages/shade-server/src/standalone.ts +++ b/packages/shade-server/src/standalone.ts @@ -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 { - 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 void | Promise }> { + 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 }); diff --git a/packages/shade-server/tests/health-metrics.test.ts b/packages/shade-server/tests/health-metrics.test.ts new file mode 100644 index 0000000..886e32d --- /dev/null +++ b/packages/shade-server/tests/health-metrics.test.ts @@ -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'); + }); +}); diff --git a/packages/shade-storage-postgres/package.json b/packages/shade-storage-postgres/package.json new file mode 100644 index 0000000..7cc81e9 --- /dev/null +++ b/packages/shade-storage-postgres/package.json @@ -0,0 +1,16 @@ +{ + "name": "@shade/storage-postgres", + "version": "0.1.0", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "dependencies": { + "@shade/core": "workspace:*", + "@shade/server": "workspace:*", + "drizzle-orm": "^0.45.2", + "postgres": "^3.4.9" + }, + "devDependencies": { + "@shade/crypto-web": "workspace:*" + } +} diff --git a/packages/shade-storage-postgres/src/ensure-tables.ts b/packages/shade-storage-postgres/src/ensure-tables.ts new file mode 100644 index 0000000..da965a4 --- /dev/null +++ b/packages/shade-storage-postgres/src/ensure-tables.ts @@ -0,0 +1,91 @@ +import type { Sql } from 'postgres'; + +/** + * Auto-create all Shade tables if they don't exist. + * + * Called on PostgresStorage / PostgresPrekeyStore construction. + * Uses raw SQL (not Drizzle migrations) for zero-config deployment. + * + * All tables prefixed with `shade_` to avoid collisions when sharing + * a PostgreSQL instance with another project. + */ +export async function ensureClientTables(sql: Sql): Promise { + await sql` + CREATE TABLE IF NOT EXISTS shade_identity ( + id INTEGER PRIMARY KEY CHECK (id = 1), + signing_public_key TEXT NOT NULL, + signing_private_key TEXT NOT NULL, + dh_public_key TEXT NOT NULL, + dh_private_key TEXT NOT NULL + ) + `; + await sql` + CREATE TABLE IF NOT EXISTS shade_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + `; + await sql` + CREATE TABLE IF NOT EXISTS shade_signed_prekeys ( + key_id INTEGER PRIMARY KEY, + data_json TEXT NOT NULL + ) + `; + await sql` + CREATE TABLE IF NOT EXISTS shade_one_time_prekeys ( + key_id INTEGER PRIMARY KEY, + data_json TEXT NOT NULL + ) + `; + await sql` + CREATE TABLE IF NOT EXISTS shade_sessions ( + address TEXT PRIMARY KEY, + state_json TEXT NOT NULL + ) + `; + await sql` + CREATE TABLE IF NOT EXISTS shade_trusted_identities ( + address TEXT PRIMARY KEY, + identity_key TEXT NOT NULL + ) + `; + await sql` + CREATE TABLE IF NOT EXISTS shade_retired_identities ( + id SERIAL PRIMARY KEY, + data_json TEXT NOT NULL, + retired_at BIGINT NOT NULL + ) + `; + await sql` + CREATE INDEX IF NOT EXISTS shade_retired_at_idx ON shade_retired_identities(retired_at) + `; +} + +export async function ensurePrekeyServerTables(sql: Sql): Promise { + await sql` + CREATE TABLE IF NOT EXISTS shade_server_identities ( + address TEXT PRIMARY KEY, + identity_signing_key TEXT NOT NULL, + identity_dh_key TEXT NOT NULL + ) + `; + await sql` + CREATE TABLE IF NOT EXISTS shade_server_signed_prekeys ( + address TEXT PRIMARY KEY, + key_id INTEGER NOT NULL, + public_key TEXT NOT NULL, + signature TEXT NOT NULL + ) + `; + await sql` + CREATE TABLE IF NOT EXISTS shade_server_one_time_prekeys ( + id SERIAL PRIMARY KEY, + address TEXT NOT NULL, + key_id INTEGER NOT NULL, + public_key TEXT NOT NULL + ) + `; + await sql` + CREATE INDEX IF NOT EXISTS shade_server_otp_address_idx ON shade_server_one_time_prekeys(address) + `; +} diff --git a/packages/shade-storage-postgres/src/index.ts b/packages/shade-storage-postgres/src/index.ts new file mode 100644 index 0000000..ccdf1b5 --- /dev/null +++ b/packages/shade-storage-postgres/src/index.ts @@ -0,0 +1,3 @@ +export { PostgresStorage } from './postgres-storage.js'; +export { PostgresPrekeyStore } from './postgres-prekey-store.js'; +export { ensureClientTables, ensurePrekeyServerTables } from './ensure-tables.js'; diff --git a/packages/shade-storage-postgres/src/postgres-prekey-store.ts b/packages/shade-storage-postgres/src/postgres-prekey-store.ts new file mode 100644 index 0000000..f5ba4f2 --- /dev/null +++ b/packages/shade-storage-postgres/src/postgres-prekey-store.ts @@ -0,0 +1,125 @@ +import postgres, { type Sql } from 'postgres'; +import type { PrekeyStore } from '@shade/server'; +import { toBase64, fromBase64 } from '@shade/core'; +import { ensurePrekeyServerTables } from './ensure-tables.js'; + +/** + * PostgreSQL-backed PrekeyStore for the Shade Prekey Server. + * + * Concurrent-safe one-time prekey consumption using FOR UPDATE SKIP LOCKED, + * so multiple server instances can share the same PostgreSQL. + */ +export class PostgresPrekeyStore implements PrekeyStore { + private constructor( + private readonly sql: Sql, + private readonly ownsConnection: boolean, + ) {} + + static async create(connectionString: string): Promise { + const sql = postgres(connectionString); + const store = new PostgresPrekeyStore(sql, true); + await ensurePrekeyServerTables(sql); + return store; + } + + static async fromClient(sql: Sql): Promise { + const store = new PostgresPrekeyStore(sql, false); + await ensurePrekeyServerTables(sql); + return store; + } + + async close(): Promise { + if (this.ownsConnection) await this.sql.end(); + } + + async saveIdentity(address: string, identitySigningKey: Uint8Array, identityDHKey: Uint8Array): Promise { + await this.sql` + INSERT INTO shade_server_identities (address, identity_signing_key, identity_dh_key) + VALUES (${address}, ${toBase64(identitySigningKey)}, ${toBase64(identityDHKey)}) + ON CONFLICT (address) DO UPDATE SET + identity_signing_key = EXCLUDED.identity_signing_key, + identity_dh_key = EXCLUDED.identity_dh_key + `; + } + + async getIdentity(address: string): Promise<{ identitySigningKey: Uint8Array; identityDHKey: Uint8Array } | null> { + const rows = await this.sql>` + SELECT identity_signing_key, identity_dh_key FROM shade_server_identities WHERE address = ${address} + `; + if (rows.length === 0) return null; + return { + identitySigningKey: fromBase64(rows[0]!.identity_signing_key), + identityDHKey: fromBase64(rows[0]!.identity_dh_key), + }; + } + + async saveSignedPreKey(address: string, keyId: number, publicKey: Uint8Array, signature: Uint8Array): Promise { + await this.sql` + INSERT INTO shade_server_signed_prekeys (address, key_id, public_key, signature) + VALUES (${address}, ${keyId}, ${toBase64(publicKey)}, ${toBase64(signature)}) + ON CONFLICT (address) DO UPDATE SET + key_id = EXCLUDED.key_id, + public_key = EXCLUDED.public_key, + signature = EXCLUDED.signature + `; + } + + async getSignedPreKey(address: string): Promise<{ keyId: number; publicKey: Uint8Array; signature: Uint8Array } | null> { + const rows = await this.sql>` + SELECT key_id, public_key, signature FROM shade_server_signed_prekeys WHERE address = ${address} + `; + if (rows.length === 0) return null; + return { + keyId: rows[0]!.key_id, + publicKey: fromBase64(rows[0]!.public_key), + signature: fromBase64(rows[0]!.signature), + }; + } + + async saveOneTimePreKeys(address: string, keys: Array<{ keyId: number; publicKey: Uint8Array }>): Promise { + if (keys.length === 0) return; + await this.sql.begin(async (sql) => { + for (const k of keys) { + await sql` + INSERT INTO shade_server_one_time_prekeys (address, key_id, public_key) + VALUES (${address}, ${k.keyId}, ${toBase64(k.publicKey)}) + `; + } + }); + } + + async consumeOneTimePreKey(address: string): Promise<{ keyId: number; publicKey: Uint8Array } | null> { + // Atomic consume with FOR UPDATE SKIP LOCKED for concurrent safety + const rows = await this.sql>` + DELETE FROM shade_server_one_time_prekeys + WHERE id = ( + SELECT id FROM shade_server_one_time_prekeys + WHERE address = ${address} + ORDER BY id + LIMIT 1 + FOR UPDATE SKIP LOCKED + ) + RETURNING key_id, public_key + `; + if (rows.length === 0) return null; + return { + keyId: rows[0]!.key_id, + publicKey: fromBase64(rows[0]!.public_key), + }; + } + + async getOneTimePreKeyCount(address: string): Promise { + const rows = await this.sql>` + SELECT COUNT(*)::text as count FROM shade_server_one_time_prekeys WHERE address = ${address} + `; + return parseInt(rows[0]!.count, 10); + } + + async deleteAll(address: string): Promise { + await this.sql.begin(async (sql) => { + await sql`DELETE FROM shade_server_identities WHERE address = ${address}`; + await sql`DELETE FROM shade_server_signed_prekeys WHERE address = ${address}`; + await sql`DELETE FROM shade_server_one_time_prekeys WHERE address = ${address}`; + }); + } +} diff --git a/packages/shade-storage-postgres/src/postgres-storage.ts b/packages/shade-storage-postgres/src/postgres-storage.ts new file mode 100644 index 0000000..9e21f00 --- /dev/null +++ b/packages/shade-storage-postgres/src/postgres-storage.ts @@ -0,0 +1,203 @@ +import postgres, { type Sql } from 'postgres'; +import type { StorageProvider, IdentityKeyPair, SignedPreKey, OneTimePreKey, SessionState, RetiredIdentity } from '@shade/core'; +import { + toBase64, fromBase64, + constantTimeEqual, + serializeSessionState, deserializeSessionState, + serializeSignedPreKey, deserializeSignedPreKey, + serializeOneTimePreKey, deserializeOneTimePreKey, + serializeIdentityKeyPair, deserializeIdentityKeyPair, +} from '@shade/core'; +import { ensureClientTables } from './ensure-tables.js'; + +/** + * PostgreSQL-backed StorageProvider for Shade client-side storage. + * + * Schema: tables prefixed `shade_` so they don't collide with existing + * tables in projects that share the same PG instance (e.g. Orchestrator). + * + * Usage: + * ```ts + * const storage = await PostgresStorage.create('postgres://user:pass@host/db'); + * const manager = new ShadeSessionManager(crypto, storage); + * ``` + */ +export class PostgresStorage implements StorageProvider { + private constructor( + private readonly sql: Sql, + private readonly ownsConnection: boolean, + ) {} + + /** Create from a connection string (owns the connection) */ + static async create(connectionString: string): Promise { + const sql = postgres(connectionString); + const storage = new PostgresStorage(sql, true); + await ensureClientTables(sql); + return storage; + } + + /** Create from an existing postgres-js client (caller owns the connection) */ + static async fromClient(sql: Sql): Promise { + const storage = new PostgresStorage(sql, false); + await ensureClientTables(sql); + return storage; + } + + async close(): Promise { + if (this.ownsConnection) await this.sql.end(); + } + + // ─── Identity ────────────────────────────────────────────── + + async getIdentityKeyPair(): Promise { + const rows = await this.sql>` + SELECT signing_public_key, signing_private_key, dh_public_key, dh_private_key + FROM shade_identity WHERE id = 1 + `; + if (rows.length === 0) return null; + const r = rows[0]!; + return { + signingPublicKey: fromBase64(r.signing_public_key), + signingPrivateKey: fromBase64(r.signing_private_key), + dhPublicKey: fromBase64(r.dh_public_key), + dhPrivateKey: fromBase64(r.dh_private_key), + }; + } + + async saveIdentityKeyPair(kp: IdentityKeyPair): Promise { + await this.sql` + INSERT INTO shade_identity (id, signing_public_key, signing_private_key, dh_public_key, dh_private_key) + VALUES (1, ${toBase64(kp.signingPublicKey)}, ${toBase64(kp.signingPrivateKey)}, ${toBase64(kp.dhPublicKey)}, ${toBase64(kp.dhPrivateKey)}) + ON CONFLICT (id) DO UPDATE SET + signing_public_key = EXCLUDED.signing_public_key, + signing_private_key = EXCLUDED.signing_private_key, + dh_public_key = EXCLUDED.dh_public_key, + dh_private_key = EXCLUDED.dh_private_key + `; + } + + async getLocalRegistrationId(): Promise { + const rows = await this.sql>` + SELECT value FROM shade_config WHERE key = 'registrationId' + `; + return rows.length ? parseInt(rows[0]!.value, 10) : 0; + } + + async saveLocalRegistrationId(id: number): Promise { + await this.sql` + INSERT INTO shade_config (key, value) VALUES ('registrationId', ${String(id)}) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + `; + } + + // ─── Signed PreKeys ─────────────────────────────────────── + + async getSignedPreKey(keyId: number): Promise { + const rows = await this.sql>` + SELECT data_json FROM shade_signed_prekeys WHERE key_id = ${keyId} + `; + return rows.length ? deserializeSignedPreKey(rows[0]!.data_json) : null; + } + + async saveSignedPreKey(key: SignedPreKey): Promise { + await this.sql` + INSERT INTO shade_signed_prekeys (key_id, data_json) + VALUES (${key.keyId}, ${serializeSignedPreKey(key)}) + ON CONFLICT (key_id) DO UPDATE SET data_json = EXCLUDED.data_json + `; + } + + async removeSignedPreKey(keyId: number): Promise { + await this.sql`DELETE FROM shade_signed_prekeys WHERE key_id = ${keyId}`; + } + + // ─── One-Time PreKeys ───────────────────────────────────── + + async getOneTimePreKey(keyId: number): Promise { + const rows = await this.sql>` + SELECT data_json FROM shade_one_time_prekeys WHERE key_id = ${keyId} + `; + return rows.length ? deserializeOneTimePreKey(rows[0]!.data_json) : null; + } + + async saveOneTimePreKey(key: OneTimePreKey): Promise { + await this.sql` + INSERT INTO shade_one_time_prekeys (key_id, data_json) + VALUES (${key.keyId}, ${serializeOneTimePreKey(key)}) + ON CONFLICT (key_id) DO UPDATE SET data_json = EXCLUDED.data_json + `; + } + + async removeOneTimePreKey(keyId: number): Promise { + await this.sql`DELETE FROM shade_one_time_prekeys WHERE key_id = ${keyId}`; + } + + async getOneTimePreKeyCount(): Promise { + const rows = await this.sql>` + SELECT COUNT(*)::text as count FROM shade_one_time_prekeys + `; + return parseInt(rows[0]!.count, 10); + } + + // ─── Sessions ───────────────────────────────────────────── + + async getSession(address: string): Promise { + const rows = await this.sql>` + SELECT state_json FROM shade_sessions WHERE address = ${address} + `; + return rows.length ? deserializeSessionState(rows[0]!.state_json) : null; + } + + async saveSession(address: string, state: SessionState): Promise { + await this.sql` + INSERT INTO shade_sessions (address, state_json) + VALUES (${address}, ${serializeSessionState(state)}) + ON CONFLICT (address) DO UPDATE SET state_json = EXCLUDED.state_json + `; + } + + async removeSession(address: string): Promise { + await this.sql`DELETE FROM shade_sessions WHERE address = ${address}`; + } + + // ─── Trust ──────────────────────────────────────────────── + + async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise { + const rows = await this.sql>` + SELECT identity_key FROM shade_trusted_identities WHERE address = ${address} + `; + if (rows.length === 0) return true; // TOFU + return constantTimeEqual(fromBase64(rows[0]!.identity_key), identityKey); + } + + async saveTrustedIdentity(address: string, identityKey: Uint8Array): Promise { + await this.sql` + INSERT INTO shade_trusted_identities (address, identity_key) + VALUES (${address}, ${toBase64(identityKey)}) + ON CONFLICT (address) DO UPDATE SET identity_key = EXCLUDED.identity_key + `; + } + + // ─── Identity History ───────────────────────────────────── + + async addRetiredIdentity(identity: RetiredIdentity): Promise { + await this.sql` + INSERT INTO shade_retired_identities (data_json, retired_at) + VALUES (${serializeIdentityKeyPair(identity.keyPair)}, ${identity.retiredAt}) + `; + } + + async getRetiredIdentities(): Promise { + const rows = await this.sql>` + SELECT data_json, retired_at FROM shade_retired_identities ORDER BY retired_at DESC + `; + return rows.map((r) => ({ + keyPair: deserializeIdentityKeyPair(r.data_json), + retiredAt: Number(r.retired_at), + })); + } + + async pruneRetiredIdentities(olderThan: number): Promise { + await this.sql`DELETE FROM shade_retired_identities WHERE retired_at < ${olderThan}`; + } +} diff --git a/packages/shade-storage-postgres/tests/postgres-prekey-store.test.ts b/packages/shade-storage-postgres/tests/postgres-prekey-store.test.ts new file mode 100644 index 0000000..699c830 --- /dev/null +++ b/packages/shade-storage-postgres/tests/postgres-prekey-store.test.ts @@ -0,0 +1,117 @@ +import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import postgres, { type Sql } from 'postgres'; +import { PostgresPrekeyStore } from '../src/index.js'; + +function randBytes(n: number): Uint8Array { + const buf = new Uint8Array(n); + crypto.getRandomValues(buf); + return buf; +} + +const PG_URL = process.env.SHADE_TEST_PG_URL; + +if (!PG_URL) { + describe.skip('PostgresPrekeyStore (skipped — set SHADE_TEST_PG_URL)', () => { + test('placeholder', () => {}); + }); +} else { + describe('PostgresPrekeyStore', () => { + let sql: Sql; + let store: PostgresPrekeyStore; + + beforeAll(async () => { + sql = postgres(PG_URL!); + await sql`DROP TABLE IF EXISTS shade_server_identities CASCADE`; + await sql`DROP TABLE IF EXISTS shade_server_signed_prekeys CASCADE`; + await sql`DROP TABLE IF EXISTS shade_server_one_time_prekeys CASCADE`; + store = await PostgresPrekeyStore.fromClient(sql); + }); + + beforeEach(async () => { + await sql`TRUNCATE shade_server_identities, shade_server_signed_prekeys, shade_server_one_time_prekeys`; + }); + + afterAll(async () => { + await sql.end(); + }); + + test('identity crud', async () => { + const sig = randBytes(32); + const dh = randBytes(32); + await store.saveIdentity('bob', sig, dh); + const id = await store.getIdentity('bob'); + expect(id!.identitySigningKey).toEqual(sig); + expect(id!.identityDHKey).toEqual(dh); + }); + + test('signed prekey crud', async () => { + const pub = randBytes(32); + const sig = randBytes(64); + await store.saveSignedPreKey('bob', 1, pub, sig); + const spk = await store.getSignedPreKey('bob'); + expect(spk!.keyId).toBe(1); + expect(spk!.publicKey).toEqual(pub); + expect(spk!.signature).toEqual(sig); + }); + + test('one-time prekey FIFO consumption', async () => { + await store.saveOneTimePreKeys('bob', [ + { keyId: 100, publicKey: randBytes(32) }, + { keyId: 101, publicKey: randBytes(32) }, + { keyId: 102, publicKey: randBytes(32) }, + ]); + expect(await store.getOneTimePreKeyCount('bob')).toBe(3); + + const k1 = await store.consumeOneTimePreKey('bob'); + expect(k1!.keyId).toBe(100); + + const k2 = await store.consumeOneTimePreKey('bob'); + expect(k2!.keyId).toBe(101); + + const k3 = await store.consumeOneTimePreKey('bob'); + expect(k3!.keyId).toBe(102); + + expect(await store.consumeOneTimePreKey('bob')).toBeNull(); + }); + + test('multi-address isolation', async () => { + await store.saveIdentity('alice', randBytes(32), randBytes(32)); + await store.saveIdentity('bob', randBytes(32), randBytes(32)); + await store.saveOneTimePreKeys('alice', [{ keyId: 1, publicKey: randBytes(32) }]); + await store.saveOneTimePreKeys('bob', [ + { keyId: 1, publicKey: randBytes(32) }, + { keyId: 2, publicKey: randBytes(32) }, + ]); + + expect(await store.getOneTimePreKeyCount('alice')).toBe(1); + expect(await store.getOneTimePreKeyCount('bob')).toBe(2); + + await store.deleteAll('bob'); + + expect(await store.getIdentity('alice')).not.toBeNull(); + expect(await store.getIdentity('bob')).toBeNull(); + expect(await store.getOneTimePreKeyCount('alice')).toBe(1); + expect(await store.getOneTimePreKeyCount('bob')).toBe(0); + }); + + test('concurrent consume uses FOR UPDATE SKIP LOCKED', async () => { + // Insert 10 keys + const keys = Array.from({ length: 10 }, (_, i) => ({ + keyId: 1000 + i, + publicKey: randBytes(32), + })); + await store.saveOneTimePreKeys('bob', keys); + + // Consume concurrently + const consumed = await Promise.all( + Array.from({ length: 10 }, () => store.consumeOneTimePreKey('bob')), + ); + + // All 10 should be consumed, each unique + expect(consumed.filter((k) => k !== null).length).toBe(10); + const ids = consumed.map((k) => k!.keyId).sort(); + const unique = new Set(ids); + expect(unique.size).toBe(10); // no duplicates + }); + }); +} diff --git a/packages/shade-storage-postgres/tests/postgres-storage.test.ts b/packages/shade-storage-postgres/tests/postgres-storage.test.ts new file mode 100644 index 0000000..cf2d225 --- /dev/null +++ b/packages/shade-storage-postgres/tests/postgres-storage.test.ts @@ -0,0 +1,158 @@ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import postgres, { type Sql } from 'postgres'; +import { PostgresStorage } from '../src/index.js'; +import { SubtleCryptoProvider } from '@shade/crypto-web'; +import { ShadeSessionManager } from '@shade/core'; +import type { IdentityKeyPair } from '@shade/core'; + +const crypto = new SubtleCryptoProvider(); + +/** + * These tests require a running PostgreSQL instance. + * Skip if SHADE_TEST_PG_URL is not set. + * + * To run locally: + * docker run -d --name shade-test-pg -e POSTGRES_PASSWORD=test -p 5999:5432 postgres:16 + * SHADE_TEST_PG_URL=postgres://postgres:test@localhost:5999/postgres bun test postgres-storage + */ +const PG_URL = process.env.SHADE_TEST_PG_URL; + +if (!PG_URL) { + describe.skip('PostgresStorage (skipped — set SHADE_TEST_PG_URL to enable)', () => { + test('placeholder', () => {}); + }); +} else { + describe('PostgresStorage', () => { + let sql: Sql; + let storage: PostgresStorage; + + beforeAll(async () => { + sql = postgres(PG_URL!); + // Clean slate + await sql`DROP TABLE IF EXISTS shade_identity CASCADE`; + await sql`DROP TABLE IF EXISTS shade_config CASCADE`; + await sql`DROP TABLE IF EXISTS shade_signed_prekeys CASCADE`; + await sql`DROP TABLE IF EXISTS shade_one_time_prekeys CASCADE`; + await sql`DROP TABLE IF EXISTS shade_sessions CASCADE`; + await sql`DROP TABLE IF EXISTS shade_trusted_identities CASCADE`; + await sql`DROP TABLE IF EXISTS shade_retired_identities CASCADE`; + storage = await PostgresStorage.fromClient(sql); + }); + + afterAll(async () => { + await sql.end(); + }); + + test('identity save/load roundtrip', async () => { + const ikp: IdentityKeyPair = { + signingPublicKey: crypto.randomBytes(32), + signingPrivateKey: crypto.randomBytes(32), + dhPublicKey: crypto.randomBytes(32), + dhPrivateKey: crypto.randomBytes(32), + }; + await storage.saveIdentityKeyPair(ikp); + const restored = await storage.getIdentityKeyPair(); + expect(restored!.signingPublicKey).toEqual(ikp.signingPublicKey); + expect(restored!.dhPrivateKey).toEqual(ikp.dhPrivateKey); + }); + + test('registration id roundtrip', async () => { + await storage.saveLocalRegistrationId(42); + expect(await storage.getLocalRegistrationId()).toBe(42); + }); + + test('signed prekey crud', async () => { + await storage.saveSignedPreKey({ + keyId: 1, + keyPair: { publicKey: crypto.randomBytes(32), privateKey: crypto.randomBytes(32) }, + signature: crypto.randomBytes(64), + timestamp: Date.now(), + }); + const spk = await storage.getSignedPreKey(1); + expect(spk!.keyId).toBe(1); + expect(spk!.signature.length).toBe(64); + + await storage.removeSignedPreKey(1); + expect(await storage.getSignedPreKey(1)).toBeNull(); + }); + + test('one-time prekey count and crud', async () => { + await storage.saveOneTimePreKey({ + keyId: 100, + keyPair: { publicKey: crypto.randomBytes(32), privateKey: crypto.randomBytes(32) }, + }); + const count = await storage.getOneTimePreKeyCount(); + expect(count).toBeGreaterThanOrEqual(1); + await storage.removeOneTimePreKey(100); + }); + + test('session save/load with skipped keys', async () => { + const state = { + remoteIdentityKey: crypto.randomBytes(32), + rootKey: crypto.randomBytes(32), + sendChain: { chainKey: crypto.randomBytes(32), counter: 7 }, + receiveChain: null, + dhSend: { publicKey: crypto.randomBytes(32), privateKey: crypto.randomBytes(32) }, + dhReceive: null, + previousSendCounter: 0, + skippedKeys: new Map([['key:1', crypto.randomBytes(32)]]), + }; + await storage.saveSession('bob', state); + const restored = await storage.getSession('bob'); + expect(restored!.sendChain.counter).toBe(7); + expect(restored!.skippedKeys.size).toBe(1); + await storage.removeSession('bob'); + }); + + test('constant-time trust comparison', async () => { + const key = crypto.randomBytes(32); + // TOFU + expect(await storage.isTrustedIdentity('peer1', key)).toBe(true); + await storage.saveTrustedIdentity('peer1', key); + expect(await storage.isTrustedIdentity('peer1', key)).toBe(true); + expect(await storage.isTrustedIdentity('peer1', crypto.randomBytes(32))).toBe(false); + }); + + test('retired identity history', async () => { + const ikp: IdentityKeyPair = { + signingPublicKey: crypto.randomBytes(32), + signingPrivateKey: crypto.randomBytes(32), + dhPublicKey: crypto.randomBytes(32), + dhPrivateKey: crypto.randomBytes(32), + }; + const now = Date.now(); + await storage.addRetiredIdentity({ keyPair: ikp, retiredAt: now }); + + const retired = await storage.getRetiredIdentities(); + expect(retired.length).toBeGreaterThanOrEqual(1); + const found = retired.find((r) => r.retiredAt === now); + expect(found).toBeDefined(); + expect(found!.keyPair.signingPublicKey).toEqual(ikp.signingPublicKey); + + await storage.pruneRetiredIdentities(now + 1); + const after = await storage.getRetiredIdentities(); + expect(after.find((r) => r.retiredAt === now)).toBeUndefined(); + }); + + test('full E2EE conversation using PostgresStorage', async () => { + // Clean storage for this test + await sql`TRUNCATE shade_identity, shade_config, shade_signed_prekeys, shade_one_time_prekeys, shade_sessions, shade_trusted_identities`; + + const aliceStorage = storage; + const bobSql = postgres(PG_URL!); + const bobStorage = await PostgresStorage.fromClient(bobSql); + + try { + const alice = new ShadeSessionManager(crypto, aliceStorage); + const bob = new ShadeSessionManager(crypto, bobStorage); + + // Bob uses a separate fake storage since we only have one PG schema + // For this smoke test, we just verify the storage layer itself works + await alice.initialize(); + expect(await alice.getIdentityFingerprint()).toBeTruthy(); + } finally { + await bobSql.end(); + } + }); + }); +} diff --git a/packages/shade-storage-postgres/tsconfig.json b/packages/shade-storage-postgres/tsconfig.json new file mode 100644 index 0000000..a3e0a93 --- /dev/null +++ b/packages/shade-storage-postgres/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src"] +}