feat(container): M-Box 1-8 — stack-agnostic standalone Docker container
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:
2026-04-11 14:29:00 +02:00
parent 467dd5b065
commit 7e0f7320a9
23 changed files with 1235 additions and 44 deletions

View 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;
}
}

View File

@@ -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;
}
}

View 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>`;
}

View File

@@ -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', {

View File

@@ -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();

View File

@@ -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>;
}