feat(container): M-Box 1-8 — stack-agnostic standalone Docker container
Some checks failed
Test / test (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
Shade now ships as a self-contained Docker image. Deploy one container per project, any stack (Bun, Python, Go, Rust, Kotlin) can talk to it via plain HTTP. Zero coupling to consumer codebases. M-Box 1: Stale identity cleanup API - touchIdentity + purgeStaleIdentities on PrekeyStore interface - Implemented for Memory, SQLite, and Postgres backends - SQLite adds last_activity_at column with migration ALTER for existing DBs - Postgres adds the same via raw SQL with IF NOT EXISTS guards - Routes call touchIdentity on register, bundle fetch, replenish - 4 new tests for the cleanup API M-Box 2: Stale cleanup background task - StaleCleanupTask runs purge on startup + every 24h (configurable) - Reads SHADE_STALE_DAYS (default 30) and SHADE_CLEANUP_INTERVAL_HOURS - Wired into standalone.ts, stopped on graceful shutdown - 5 new tests for the task M-Box 3: Observer baked into the container - standalone.ts conditionally mounts @shade/observer at /shade-observer when SHADE_OBSERVER_TOKEN is set (and >= 16 chars) - Shared PrekeyServerEvents emitter feeds both routes and observer - @shade/observer added as optional dependency of @shade/server M-Box 4: Dockerfile with dashboard build - Multi-stage build: oven/bun:1 builder → oven/bun:1-alpine runtime - COPY packages/ wholesale so workspace lockfile resolves cleanly - RUN bun run build inside shade-dashboard → dist/ → observer/dist/ - Non-root shade user, /data volume, healthcheck, env defaults - Final image: 260 MB M-Box 5: OpenAPI spec for stack-agnostic clients - packages/shade-server/openapi.yaml documents all 9 endpoints with request/response schemas, security (Ed25519 signatures + bearer token) - createOpenApiRoutes serves /openapi.yaml and /docs (Redoc viewer) - Any language can generate a client with openapi-generator M-Box 6: Docker CI pipeline - .gitea/workflows/docker.yml builds + pushes on git tag v* - scripts/build-docker.ts for local builds, supports --push with GITEA_TOKEN - Root package.json: build:docker, publish:docker scripts M-Box 7: Deployment documentation - packages/shade-server/README rewritten: 5-line quickstart with the image - docs/DEPLOYMENT.md: full reference, env vars, backup, Dokploy, PG setup - examples/05-dokploy-deployment/docker-compose.yml updated to pull published image (gt.zyon.no/stian/shade-prekey:latest) - Root README deployment section rewritten M-Box 8: End-to-end verification - Image builds locally (bun run build:docker) - /health, /openapi.yaml, /docs, /metrics, /shade-observer all respond - 401 without observer token, 200 with - Real SDK client round-trip: Alice → container → Bob → reply → Alice - Persistence: identity + prekeys survive container restart (count 20→18 as expected from two bundle fetches) 285 tests passing, 0 failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
70
packages/shade-server/src/cleanup.ts
Normal file
70
packages/shade-server/src/cleanup.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { PrekeyStore } from './store.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* Background task that periodically purges stale identities from the store.
|
||||
*
|
||||
* "Stale" = no activity (register, fetch bundle, replenish, delete) for
|
||||
* more than `staleDays` days. The threshold and interval are configurable
|
||||
* via env vars:
|
||||
* SHADE_STALE_DAYS (default 30)
|
||||
* SHADE_CLEANUP_INTERVAL_HOURS (default 24)
|
||||
*/
|
||||
export class StaleCleanupTask {
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private running = false;
|
||||
private readonly staleMs: number;
|
||||
private readonly intervalMs: number;
|
||||
|
||||
constructor(
|
||||
private readonly store: PrekeyStore,
|
||||
options: { staleDays?: number; intervalHours?: number } = {},
|
||||
) {
|
||||
const staleDays = options.staleDays
|
||||
?? Number(process.env.SHADE_STALE_DAYS ?? 30);
|
||||
const intervalHours = options.intervalHours
|
||||
?? Number(process.env.SHADE_CLEANUP_INTERVAL_HOURS ?? 24);
|
||||
this.staleMs = staleDays * 24 * 60 * 60 * 1000;
|
||||
this.intervalMs = intervalHours * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
// Run once immediately (so operators see it in the logs at startup)
|
||||
this.runOnce().catch((err) => {
|
||||
logger.error('Initial stale cleanup failed', { error: String(err) });
|
||||
});
|
||||
this.timer = setInterval(() => {
|
||||
this.runOnce().catch((err) => {
|
||||
logger.error('Stale cleanup failed', { error: String(err) });
|
||||
});
|
||||
}, this.intervalMs);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.running = false;
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Run a single cleanup cycle. Exposed for tests and manual triggers. */
|
||||
async runOnce(): Promise<number> {
|
||||
const count = await this.store.purgeStaleIdentities(this.staleMs);
|
||||
if (count > 0) {
|
||||
logger.info('Stale cleanup purged identities', {
|
||||
count,
|
||||
staleDays: this.staleMs / (24 * 60 * 60 * 1000),
|
||||
});
|
||||
} else {
|
||||
logger.debug('Stale cleanup: nothing to purge');
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
get isRunning(): boolean {
|
||||
return this.running;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export class MemoryPrekeyStore implements PrekeyStore {
|
||||
private identities = new Map<string, IdentityRecord>();
|
||||
private signedPreKeys = new Map<string, SignedPreKeyRecord>();
|
||||
private oneTimePreKeys = new Map<string, OneTimePreKeyRecord[]>();
|
||||
private lastActivity = new Map<string, number>();
|
||||
|
||||
async saveIdentity(address: string, identitySigningKey: Uint8Array, identityDHKey: Uint8Array): Promise<void> {
|
||||
this.identities.set(address, { identitySigningKey, identityDHKey });
|
||||
@@ -60,5 +61,32 @@ export class MemoryPrekeyStore implements PrekeyStore {
|
||||
this.identities.delete(address);
|
||||
this.signedPreKeys.delete(address);
|
||||
this.oneTimePreKeys.delete(address);
|
||||
this.lastActivity.delete(address);
|
||||
}
|
||||
|
||||
// ─── Stale cleanup ────────────────────────────────────
|
||||
|
||||
async touchIdentity(address: string): Promise<void> {
|
||||
this.lastActivity.set(address, Date.now());
|
||||
}
|
||||
|
||||
async purgeStaleIdentities(olderThanMs: number): Promise<number> {
|
||||
const cutoff = Date.now() - olderThanMs;
|
||||
const stale: string[] = [];
|
||||
for (const [address, ts] of this.lastActivity) {
|
||||
if (ts < cutoff) stale.push(address);
|
||||
}
|
||||
// Also purge identities that have a row but were never touched
|
||||
// (last_activity = 0/undefined before first touch)
|
||||
for (const address of this.identities.keys()) {
|
||||
if (!this.lastActivity.has(address)) stale.push(address);
|
||||
}
|
||||
for (const address of stale) {
|
||||
this.identities.delete(address);
|
||||
this.signedPreKeys.delete(address);
|
||||
this.oneTimePreKeys.delete(address);
|
||||
this.lastActivity.delete(address);
|
||||
}
|
||||
return stale.length;
|
||||
}
|
||||
}
|
||||
|
||||
54
packages/shade-server/src/openapi.ts
Normal file
54
packages/shade-server/src/openapi.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Hono } from 'hono';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
/**
|
||||
* Serves the OpenAPI spec at /openapi.yaml and a Redoc HTML viewer at /docs.
|
||||
*
|
||||
* Any language can fetch /openapi.yaml and generate a client with
|
||||
* openapi-generator. The /docs HTML viewer is a thin Redoc wrapper.
|
||||
*/
|
||||
export function createOpenApiRoutes(specPath?: string): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
const defaultPath = join(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'openapi.yaml',
|
||||
);
|
||||
const path = specPath ?? defaultPath;
|
||||
|
||||
app.get('/openapi.yaml', (c) => {
|
||||
if (!existsSync(path)) {
|
||||
return c.text('OpenAPI spec not found', 404);
|
||||
}
|
||||
const content = readFileSync(path, 'utf-8');
|
||||
c.header('Content-Type', 'application/yaml; charset=utf-8');
|
||||
return c.body(content);
|
||||
});
|
||||
|
||||
app.get('/docs', (c) => {
|
||||
c.header('Content-Type', 'text/html; charset=utf-8');
|
||||
return c.body(redocHtml());
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
function redocHtml(): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Shade API Reference</title>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
|
||||
<style>body { margin: 0; padding: 0; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<redoc spec-url="/openapi.yaml"></redoc>
|
||||
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -157,6 +157,9 @@ export function createPrekeyRoutes(
|
||||
};
|
||||
}
|
||||
|
||||
// Update activity so stale cleanup doesn't purge active addresses
|
||||
await store.touchIdentity(address);
|
||||
|
||||
events?.emit('server.bundle_fetched', {
|
||||
address,
|
||||
hadOneTimePreKey: oneTimePreKey != null,
|
||||
@@ -192,6 +195,7 @@ export function createPrekeyRoutes(
|
||||
publicKey: b64ToBytes(k.publicKey),
|
||||
}));
|
||||
await store.saveOneTimePreKeys(addr, keys);
|
||||
await store.touchIdentity(addr);
|
||||
|
||||
const count = await store.getOneTimePreKeyCount(addr);
|
||||
events?.emit('server.prekeys_replenished', {
|
||||
|
||||
@@ -3,6 +3,9 @@ import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import { createPrekeyRoutes } from './routes.js';
|
||||
import { createHealthRoutes } from './health.js';
|
||||
import { createMetricsRoutes, metricsMiddleware } from './metrics.js';
|
||||
import { createOpenApiRoutes } from './openapi.js';
|
||||
import { PrekeyServerEvents } from './events.js';
|
||||
import { StaleCleanupTask } from './cleanup.js';
|
||||
import { logger } from './logger.js';
|
||||
import type { PrekeyStore } from './store.js';
|
||||
|
||||
@@ -41,13 +44,47 @@ function maskUrl(url: string): string {
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
const store = await createStore();
|
||||
const events = new PrekeyServerEvents();
|
||||
|
||||
// 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));
|
||||
app.route('/', createOpenApiRoutes());
|
||||
app.route('/', createPrekeyRoutes(store, crypto, { events }));
|
||||
|
||||
// ─── Optional: Observer + Dashboard ──────────────────────────
|
||||
|
||||
const observerToken = process.env.SHADE_OBSERVER_TOKEN;
|
||||
if (observerToken && observerToken.length >= 16) {
|
||||
try {
|
||||
const { createObserver } = await import('@shade/observer');
|
||||
const observer = createObserver({
|
||||
token: observerToken,
|
||||
serverEvents: events,
|
||||
});
|
||||
app.route('/shade-observer', observer);
|
||||
logger.info('Observer enabled', { path: '/shade-observer/dashboard/' });
|
||||
} catch (err) {
|
||||
logger.warn('Observer module not available, skipping', { error: String(err) });
|
||||
}
|
||||
} else if (observerToken) {
|
||||
logger.warn('SHADE_OBSERVER_TOKEN is set but too short (needs ≥16 chars), observer disabled');
|
||||
} else {
|
||||
logger.info('Observer disabled (SHADE_OBSERVER_TOKEN not set)');
|
||||
}
|
||||
|
||||
// ─── Stale cleanup task ──────────────────────────────────────
|
||||
|
||||
const cleanupTask = new StaleCleanupTask(store);
|
||||
cleanupTask.start();
|
||||
logger.info('Stale cleanup task started', {
|
||||
staleDays: Number(process.env.SHADE_STALE_DAYS ?? 30),
|
||||
intervalHours: Number(process.env.SHADE_CLEANUP_INTERVAL_HOURS ?? 24),
|
||||
});
|
||||
|
||||
// ─── Start HTTP server ───────────────────────────────────────
|
||||
|
||||
const port = Number(process.env.PORT ?? 3900);
|
||||
|
||||
@@ -64,6 +101,7 @@ async function shutdown(signal: string) {
|
||||
logger.info('Shutting down', { signal });
|
||||
|
||||
try {
|
||||
cleanupTask.stop();
|
||||
server.stop();
|
||||
if ('close' in store && typeof store.close === 'function') {
|
||||
await store.close();
|
||||
|
||||
@@ -45,4 +45,22 @@ export interface PrekeyStore {
|
||||
|
||||
/** Delete all keys for an address */
|
||||
deleteAll(address: string): Promise<void>;
|
||||
|
||||
// ─── Stale cleanup ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Update the last-activity timestamp for an address to the current time.
|
||||
* Called on every read or write that references the address, so stale
|
||||
* cleanup only removes addresses nobody has touched in a long time.
|
||||
*/
|
||||
touchIdentity(address: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Purge every identity whose last activity is older than `olderThanMs`
|
||||
* milliseconds ago. Cascades deletion to signed prekeys and one-time
|
||||
* prekeys for the affected addresses.
|
||||
*
|
||||
* @returns the number of addresses purged
|
||||
*/
|
||||
purgeStaleIdentities(olderThanMs: number): Promise<number>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user