release(v4.0.0): Shade GA — V3.x consolidation + audit prep
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled

V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.

Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
  Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
  bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
  shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
  non-realtime stack.

Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
  version 4.0.0 on /health.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 18:35:35 +02:00
parent 8b055912b7
commit e6fdf31b49
298 changed files with 37909 additions and 256 deletions

View File

@@ -4,11 +4,18 @@ import { createPrekeyRoutes } from './routes.js';
import { MemoryPrekeyStore } from './memory-store.js';
import type { PrekeyStore } from './store.js';
import type { PrekeyServerEvents } from './events.js';
import {
KeyTransparencyService,
type KeyTransparencyConfig,
} from './kt-integration.js';
export { createPrekeyRoutes } from './routes.js';
export { MemoryPrekeyStore } from './memory-store.js';
export type { PrekeyStore } from './store.js';
export { verifyPayload, signPayload, canonicalizePayload, validateAddress } from './auth.js';
export { KeyTransparencyService, encodeSthForWire, encodeProofForWire } from './kt-integration.js';
export type { KeyTransparencyConfig } from './kt-integration.js';
export { createKTRoutes } from './kt-routes.js';
/**
* Create a standalone Shade Prekey Server.
@@ -29,6 +36,8 @@ export function createPrekeyServer(options: {
store?: PrekeyStore;
disableRateLimit?: boolean;
events?: PrekeyServerEvents;
/** Existing KT service (already initialized via `KeyTransparencyService.create`). */
keyTransparency?: KeyTransparencyService;
}): Hono {
const store = options.store ?? new MemoryPrekeyStore();
const routesOptions: Parameters<typeof createPrekeyRoutes>[2] = {};
@@ -38,9 +47,35 @@ export function createPrekeyServer(options: {
if (options.events !== undefined) {
routesOptions.events = options.events;
}
if (options.keyTransparency !== undefined) {
routesOptions.keyTransparency = options.keyTransparency;
}
return createPrekeyRoutes(store, options.crypto, routesOptions);
}
/**
* Convenience: create both the KT service and the prekey server in one call.
* Async because `KeyTransparencyService.create` reads existing state.
*/
export async function createPrekeyServerWithKT(options: {
crypto: CryptoProvider;
store?: PrekeyStore;
disableRateLimit?: boolean;
events?: PrekeyServerEvents;
keyTransparency: KeyTransparencyConfig;
}): Promise<{ app: Hono; kt: KeyTransparencyService }> {
const kt = await KeyTransparencyService.create(options.crypto, options.keyTransparency);
const passOpts: Parameters<typeof createPrekeyServer>[0] = {
crypto: options.crypto,
keyTransparency: kt,
};
if (options.store !== undefined) passOpts.store = options.store;
if (options.disableRateLimit !== undefined) passOpts.disableRateLimit = options.disableRateLimit;
if (options.events !== undefined) passOpts.events = options.events;
const app = createPrekeyServer(passOpts);
return { app, kt };
}
export { RateLimiter, MemoryRateLimitStore } from './rate-limit.js';
export type { RateLimitStore, RateLimitConfig } from './rate-limit.js';
export { PrekeyServerEvents, shortHash as serverShortHash } from './events.js';

View File

@@ -0,0 +1,216 @@
/**
* Key-Transparency integration for the Shade prekey server.
*
* The prekey server is the *source of truth* for which prekey bundle is
* currently published for each address. Without KT a malicious server
* could swap a bundle without anyone noticing. With KT enabled:
*
* - Every register / delete operation appends a leaf to an append-only
* Merkle log via `KTLogManager.recordRegister` / `recordDelete`.
* - After each mutation the manager re-signs and publishes a fresh STH.
* - GET /v1/keys/bundle/:address attaches a `ktProof` to its response,
* so the client can verify inclusion + freshness.
* - GET /v1/kt/sth and friends expose the log to witnesses.
*
* KT is **opt-in**: pass `keyTransparency` to `createPrekeyServer`. When
* absent, the server behaves exactly as before — proof fields are simply
* not added to the bundle response.
*/
import type { CryptoProvider } from '@shade/core';
import {
KTLogManager,
type KTLogStore,
computeBundleHash,
ktProofToWire,
sthToWire,
type KTProof,
type KTProofWire,
type STHWire,
type SignedTreeHead,
} from '@shade/key-transparency';
export interface KeyTransparencyConfig {
/** Persistent store for the log + index + STH set. */
store: KTLogStore;
/** Operator's STH signing key (32-byte Ed25519 seed). */
signingPrivateKey: Uint8Array;
/** Operator's STH signing public key (32-byte Ed25519). */
signingPublicKey: Uint8Array;
/**
* Heartbeat interval — minimum gap between fresh STHs even when no
* mutations occur. Default 10 minutes; set to 0 to disable.
*/
heartbeatIntervalMs?: number;
/** Time source override (testing). */
now?: () => number;
}
/**
* Wraps a `KTLogManager` with the bookkeeping the server cares about:
* - Serializes mutations (single-writer guarantee).
* - Caches the latest STH so bundle-fetch is hot-path-fast.
* - Schedules / surfaces heartbeats.
* - Lazily backfills index entries from the prekey-server's existing
* state when KT is first turned on.
*/
export class KeyTransparencyService {
private readonly mgr: KTLogManager;
private readonly store: KTLogStore;
private readonly heartbeatIntervalMs: number;
private readonly now: () => number;
private latest: SignedTreeHead | null = null;
private mutex: Promise<unknown> = Promise.resolve();
private constructor(
mgr: KTLogManager,
store: KTLogStore,
opts: { heartbeatIntervalMs?: number; now?: () => number },
) {
this.mgr = mgr;
this.store = store;
this.heartbeatIntervalMs = opts.heartbeatIntervalMs ?? 10 * 60 * 1000;
this.now = opts.now ?? (() => Date.now());
}
static async create(crypto: CryptoProvider, cfg: KeyTransparencyConfig): Promise<KeyTransparencyService> {
const mgr = await KTLogManager.create({
crypto,
store: cfg.store,
signingPrivateKey: cfg.signingPrivateKey,
signingPublicKey: cfg.signingPublicKey,
...(cfg.now ? { now: cfg.now } : {}),
});
const svc = new KeyTransparencyService(mgr, cfg.store, {
...(cfg.heartbeatIntervalMs !== undefined ? { heartbeatIntervalMs: cfg.heartbeatIntervalMs } : {}),
...(cfg.now ? { now: cfg.now } : {}),
});
// Cache or generate the initial STH so bundle responses always have one.
const existing = await cfg.store.getLatestSTH();
if (existing) {
svc.latest = existing;
} else {
svc.latest = await mgr.publishSTH();
}
return svc;
}
/**
* Run a mutation under the manager's serial lock and refresh the STH.
*/
private async withLock<T>(fn: () => Promise<T>): Promise<T> {
const prev = this.mutex;
let resolveNext: () => void;
const next = new Promise<void>((res) => {
resolveNext = res;
});
this.mutex = next;
try {
await prev.catch(() => {});
return await fn();
} finally {
resolveNext!();
}
}
async recordRegister(address: string, bundle: {
identitySigningKey: Uint8Array;
identityDHKey: Uint8Array;
signedPreKey: { keyId: number; publicKey: Uint8Array; signature: Uint8Array };
}): Promise<SignedTreeHead> {
return this.withLock(async () => {
await this.mgr.recordRegister(address, computeBundleHash(bundle));
this.latest = await this.mgr.publishSTH();
return this.latest!;
});
}
async recordDelete(address: string): Promise<SignedTreeHead> {
return this.withLock(async () => {
await this.mgr.recordDelete(address);
this.latest = await this.mgr.publishSTH();
return this.latest!;
});
}
/**
* Build a proof for a freshly-fetched bundle. Returns null if the
* address has no live entry (caller can request an absence proof
* via `buildAbsenceProof` instead).
*/
async buildBundleInclusion(address: string): Promise<KTProof | null> {
const sth = await this.maybeHeartbeat();
return this.mgr.buildBundleInclusionProof(address, sth);
}
async buildAbsence(address: string): Promise<KTProof | null> {
const sth = await this.maybeHeartbeat();
return this.mgr.buildBundleAbsenceProof(address, sth);
}
/** Latest STH — issuing a heartbeat first if the cached one is stale. */
async getLatestSTH(): Promise<SignedTreeHead> {
return this.maybeHeartbeat();
}
/** Historical STH at a specific tree size. */
async getSTHByTreeSize(treeSize: number): Promise<SignedTreeHead | null> {
return this.store.getSTHByTreeSize(treeSize);
}
/** All persisted STHs in a time window — used by witness backfill. */
async listSTHs(fromTimestampMs?: number, toTimestampMs?: number): Promise<SignedTreeHead[]> {
return this.store.listSTHs(fromTimestampMs, toTimestampMs);
}
/** Build a consistency proof for `from → to`. */
async buildConsistencyProof(fromTreeSize: number, toTreeSize?: number): Promise<{
fromTreeSize: number;
toTreeSize: number;
proof: Uint8Array[];
}> {
return this.withLock(async () => {
const targetSize = toTreeSize ?? this.mgr.getTreeSize();
const proof = await this.mgr.buildHistoricalConsistencyProof(fromTreeSize, targetSize);
return { fromTreeSize, toTreeSize: targetSize, proof };
});
}
/** STH signing public key — operators expose this to clients OOB. */
getSigningPublicKey(): Uint8Array {
return this.mgr.getSigningPublicKey();
}
getLogId(): Uint8Array {
return this.mgr.getLogId();
}
private async maybeHeartbeat(): Promise<SignedTreeHead> {
if (!this.latest) {
return this.withLock(async () => {
this.latest = await this.mgr.publishSTH();
return this.latest!;
});
}
if (this.heartbeatIntervalMs <= 0) return this.latest;
const age = this.now() - this.latest.timestampMs;
if (age < this.heartbeatIntervalMs) return this.latest;
return this.withLock(async () => {
// Re-check age inside the lock — another caller may have published.
const ageNow = this.now() - (this.latest?.timestampMs ?? 0);
if (ageNow < this.heartbeatIntervalMs) return this.latest!;
this.latest = await this.mgr.publishSTH();
return this.latest!;
});
}
}
/** Helpers to encode an STH for the wire (base64). */
export function encodeSthForWire(sth: SignedTreeHead): STHWire {
return sthToWire(sth, (b) => Buffer.from(b).toString('base64'));
}
export function encodeProofForWire(proof: KTProof): KTProofWire {
return ktProofToWire(proof);
}

View File

@@ -0,0 +1,74 @@
import { Hono } from 'hono';
import { ValidationError } from '@shade/core';
import type { KeyTransparencyService } from './kt-integration.js';
import { encodeSthForWire } from './kt-integration.js';
/**
* Mountable routes that expose the KT log to clients and witnesses.
*
* GET /v1/kt/log_id — public-key + log_id (operator pinning)
* GET /v1/kt/sth — latest signed tree head
* GET /v1/kt/sth/:treeSize — historical STH at a specific tree_size
* GET /v1/kt/consistency — consistency proof between two tree_sizes
*
* These are intentionally **anonymous & read-only** so witnesses can
* poll without sharing identity. Rate-limiting is the prekey-server's
* existing fetch RL bucket.
*/
export function createKTRoutes(svc: KeyTransparencyService): Hono {
const app = new Hono();
app.get('/log_id', (c) => {
const logId = svc.getLogId();
const pub = svc.getSigningPublicKey();
return c.json({
logId: Buffer.from(logId).toString('base64'),
publicKey: Buffer.from(pub).toString('base64'),
});
});
app.get('/sth', async (c) => {
const sth = await svc.getLatestSTH();
return c.json(encodeSthForWire(sth));
});
app.get('/sth/:treeSize', async (c) => {
const sizeRaw = c.req.param('treeSize');
const size = Number(sizeRaw);
if (!Number.isFinite(size) || size < 0 || size !== Math.floor(size)) {
throw new ValidationError('treeSize must be a non-negative integer');
}
const sth = await svc.getSTHByTreeSize(size);
if (!sth) {
return c.json({ error: 'STH not found at that tree_size', code: 'SHADE_NOT_FOUND' }, 404);
}
return c.json(encodeSthForWire(sth));
});
app.get('/consistency', async (c) => {
const fromRaw = c.req.query('from');
const toRaw = c.req.query('to');
if (fromRaw === undefined) {
throw new ValidationError('from query param required');
}
const from = Number(fromRaw);
if (!Number.isFinite(from) || from < 0 || from !== Math.floor(from)) {
throw new ValidationError('from must be a non-negative integer');
}
let to: number | undefined;
if (toRaw !== undefined) {
to = Number(toRaw);
if (!Number.isFinite(to) || to < from || to !== Math.floor(to)) {
throw new ValidationError('to must be an integer >= from');
}
}
const result = await svc.buildConsistencyProof(from, to);
return c.json({
fromTreeSize: result.fromTreeSize,
toTreeSize: result.toTreeSize,
proof: result.proof.map((p) => Buffer.from(p).toString('base64')),
});
});
return app;
}

View File

@@ -5,6 +5,16 @@ import type { PrekeyStore } from './store.js';
import { verifyPayload, validateAddress } from './auth.js';
import { RateLimiter, MemoryRateLimitStore, REGISTER_LIMIT, FETCH_LIMIT, REPLENISH_LIMIT, DELETE_LIMIT } from './rate-limit.js';
import { PrekeyServerEvents, shortHash } from './events.js';
import {
ATTR_ERROR_CODE,
ATTR_HTTP_STATUS,
ATTR_ROUTE,
NOOP_HOOK,
type ObservabilityHook,
} from '@shade/observability';
import type { KeyTransparencyService } from './kt-integration.js';
import { encodeProofForWire, encodeSthForWire } from './kt-integration.js';
import { createKTRoutes } from './kt-routes.js';
/** Max POST body size in bytes (64KB) */
const MAX_BODY_SIZE = 64 * 1024;
@@ -26,6 +36,22 @@ export interface PrekeyRoutesOptions {
disableRateLimit?: boolean;
/** Optional event emitter for observability. */
events?: PrekeyServerEvents;
/**
* Optional OTel observability hook. When supplied (and the runtime gate
* is on), each request gets a `shade.prekey.<route>` span with route
* + HTTP-status attributes. PII-safe: never logs the address path
* parameter or client IP.
*/
observability?: ObservabilityHook;
/**
* Optional Key-Transparency service (V3.12). When provided:
* - Every `register` and `delete` mutation is committed to the log.
* - `GET /v1/keys/bundle/:address` includes a `ktProof` field.
* - `/v1/kt/*` routes are mounted (latest STH, historical STHs,
* consistency proofs, log_id pinning info).
* When absent, the server is byte-compatible with pre-V3.12 clients.
*/
keyTransparency?: KeyTransparencyService;
}
export function createPrekeyRoutes(
@@ -35,6 +61,35 @@ export function createPrekeyRoutes(
): Hono {
const app = new Hono();
const events = options.events;
const observability = options.observability ?? NOOP_HOOK;
const kt = options.keyTransparency;
// Per-request span middleware — runs first so it covers handlers AND
// the global error handler. Span name is the route template (e.g.
// `/v1/keys/bundle/:address`), so cardinality stays bounded and the
// address itself never enters span data.
app.use('*', async (c, next) => {
const route = c.req.routePath ?? c.req.path ?? '<unknown>';
const span = observability.startSpan('shade.prekey.request', {
[ATTR_ROUTE]: route,
});
try {
await next();
span.setAttribute(ATTR_HTTP_STATUS, c.res.status);
span.setStatus(c.res.status >= 500 ? 'error' : 'ok');
} catch (err) {
const code =
err instanceof ShadeError
? err.code ?? 'SHADE_ERROR'
: 'SHADE_INTERNAL';
span.setAttribute(ATTR_ERROR_CODE, code);
span.recordException(err);
span.setStatus('error', code);
throw err;
} finally {
span.end();
}
});
// Rate limiters (one per route, per IP or per identity)
const rlStore = new MemoryRateLimitStore();
@@ -115,6 +170,21 @@ export function createPrekeyRoutes(
await store.saveOneTimePreKeys(addr, keys);
}
// Commit to KT log (if enabled). The bundle covered by the commitment
// is { signing_key, dh_key, signed_prekey } — one-time prekeys are
// intentionally excluded so OTP rotation doesn't churn the log.
if (kt) {
await kt.recordRegister(addr, {
identitySigningKey: signingKey,
identityDHKey: dhKey,
signedPreKey: {
keyId: signedPreKey.keyId,
publicKey: b64ToBytes(signedPreKey.publicKey),
signature: b64ToBytes(signedPreKey.signature),
},
});
}
if (events) {
const hash = await shortHash(signingKey);
events.emit('server.identity_registered', { address: addr, identityKeyHash: hash });
@@ -130,6 +200,33 @@ export function createPrekeyRoutes(
const identity = await store.getIdentity(address);
if (!identity) {
// KT-enabled: pin the negative answer to a tree state. If the
// address has been tombstoned we serve the tombstone (inclusion)
// proof; otherwise an absence proof.
if (kt) {
const inclusion = await kt.buildBundleInclusion(address);
if (inclusion) {
return c.json(
{
error: 'Address not found',
code: 'SHADE_NOT_FOUND',
ktProof: encodeProofForWire(inclusion),
},
404,
);
}
const absence = await kt.buildAbsence(address);
if (absence) {
return c.json(
{
error: 'Address not found',
code: 'SHADE_NOT_FOUND',
ktProof: encodeProofForWire(absence),
},
404,
);
}
}
return c.json({ error: 'Address not found', code: 'SHADE_NOT_FOUND' }, 404);
}
@@ -157,6 +254,19 @@ export function createPrekeyRoutes(
};
}
if (kt) {
const proof = await kt.buildBundleInclusion(address);
if (proof) {
bundle.ktProof = encodeProofForWire(proof);
} else {
// No live entry in the index — fall back to STH so client at
// least sees a fresh tree-head, then surfaces "no proof available"
// as a soft warning.
const sth = await kt.getLatestSTH();
bundle.ktSth = encodeSthForWire(sth);
}
}
// Update activity so stale cleanup doesn't purge active addresses
await store.touchIdentity(address);
@@ -228,10 +338,17 @@ export function createPrekeyRoutes(
await verifyPayload(crypto, identity.identitySigningKey, { ...body, address });
await store.deleteAll(address);
if (kt) {
await kt.recordDelete(address);
}
events?.emit('server.identity_deleted', { address });
return c.json({ ok: true });
});
if (kt) {
app.route('/v1/kt', createKTRoutes(kt));
}
return app;
}

View File

@@ -1,5 +1,13 @@
import { Hono } from 'hono';
import { SubtleCryptoProvider } from '@shade/crypto-web';
import {
createInboxRoutes,
createBridgeRoutes,
InboxServerEvents,
InboxPruneTask,
MemoryInboxStore,
type InboxStore,
} from '@shade/inbox-server';
import { createPrekeyRoutes } from './routes.js';
import { createHealthRoutes } from './health.js';
import { createMetricsRoutes, metricsMiddleware } from './metrics.js';
@@ -8,8 +16,13 @@ import { PrekeyServerEvents } from './events.js';
import { StaleCleanupTask } from './cleanup.js';
import { logger } from './logger.js';
import type { PrekeyStore } from './store.js';
import { KeyTransparencyService } from './kt-integration.js';
import {
MemoryKTLogStore,
type KTLogStore,
} from '@shade/key-transparency';
const VERSION = '1.0.0';
const VERSION = '4.0.0';
async function createStore(): Promise<PrekeyStore & { close?: () => void | Promise<void> }> {
const sqlitePath = process.env.SHADE_PREKEY_DB_PATH;
@@ -32,6 +45,89 @@ async function createStore(): Promise<PrekeyStore & { close?: () => void | Promi
return new MemoryPrekeyStore();
}
async function createInboxStore(): Promise<InboxStore & { close?: () => void | Promise<void> }> {
const sqlitePath = process.env.SHADE_INBOX_DB_PATH;
const pgUrl = process.env.SHADE_INBOX_PG_URL ?? process.env.SHADE_PREKEY_PG_URL;
if (pgUrl && process.env.SHADE_INBOX_PG_URL) {
const { PostgresInboxStore } = await import('@shade/storage-postgres');
logger.info('Using PostgreSQL inbox store', { url: maskUrl(pgUrl) });
return PostgresInboxStore.create(pgUrl);
}
if (sqlitePath) {
const { SqliteInboxStore } = await import('@shade/storage-sqlite');
logger.info('Using SQLite inbox store', { path: sqlitePath });
return new SqliteInboxStore(sqlitePath);
}
if (pgUrl) {
const { PostgresInboxStore } = await import('@shade/storage-postgres');
logger.info('Using PostgreSQL inbox store (sharing prekey URL)', { url: maskUrl(pgUrl) });
return PostgresInboxStore.create(pgUrl);
}
logger.warn('Using in-memory inbox store — data will not persist across restarts');
return new MemoryInboxStore();
}
async function maybeCreateKT(): Promise<KeyTransparencyService | undefined> {
const skPriv = process.env.SHADE_KT_SIGNING_PRIVATE_KEY;
const skPub = process.env.SHADE_KT_SIGNING_PUBLIC_KEY;
if (!skPriv || !skPub) {
if (skPriv || skPub) {
logger.warn(
'Key Transparency requires BOTH SHADE_KT_SIGNING_PRIVATE_KEY and SHADE_KT_SIGNING_PUBLIC_KEY — KT disabled',
);
} else {
logger.info('Key Transparency disabled (signing keys not configured)');
}
return undefined;
}
let signingPrivateKey: Uint8Array;
let signingPublicKey: Uint8Array;
try {
signingPrivateKey = new Uint8Array(Buffer.from(skPriv, 'base64'));
signingPublicKey = new Uint8Array(Buffer.from(skPub, 'base64'));
} catch {
logger.error('SHADE_KT_SIGNING_*_KEY must be base64 — KT disabled');
return undefined;
}
if (signingPrivateKey.length !== 32 || signingPublicKey.length !== 32) {
logger.error(
`SHADE_KT_SIGNING_*_KEY must decode to 32 bytes (priv=${signingPrivateKey.length}, pub=${signingPublicKey.length}) — KT disabled`,
);
return undefined;
}
let ktStore: KTLogStore;
const ktPg = process.env.SHADE_KT_PG_URL ?? process.env.SHADE_PREKEY_PG_URL;
if (ktPg) {
const { PostgresKTLogStore } = await import('@shade/storage-postgres');
logger.info('Using PostgreSQL KT log store', { url: maskUrl(ktPg) });
ktStore = await PostgresKTLogStore.create(ktPg);
} else {
logger.warn('Using in-memory KT log store — KT data will not persist across restarts');
ktStore = new MemoryKTLogStore();
}
const heartbeatRaw = process.env.SHADE_KT_HEARTBEAT_MS;
const heartbeatIntervalMs = heartbeatRaw ? Number(heartbeatRaw) : 10 * 60 * 1000;
const svc = await KeyTransparencyService.create(crypto, {
store: ktStore,
signingPrivateKey,
signingPublicKey,
heartbeatIntervalMs,
});
logger.info('Key Transparency enabled', {
logId: Buffer.from(svc.getLogId()).toString('hex').slice(0, 16) + '…',
heartbeatIntervalMs,
});
return svc;
}
function maskUrl(url: string): string {
try {
const u = new URL(url);
@@ -46,13 +142,42 @@ const crypto = new SubtleCryptoProvider();
const store = await createStore();
const events = new PrekeyServerEvents();
// Inbox store + events (V3.6 store-and-forward relay)
const inboxStore = await createInboxStore();
const inboxEvents = new InboxServerEvents();
// ─── Optional: Key Transparency (V3.12) ──────────────────────
//
// Enabled when both SHADE_KT_SIGNING_PRIVATE_KEY and
// SHADE_KT_SIGNING_PUBLIC_KEY are set (base64-encoded 32-byte
// Ed25519 seeds). Storage: PostgresKTLogStore when
// SHADE_KT_PG_URL (or SHADE_PREKEY_PG_URL) is set, else memory.
const kt = await maybeCreateKT();
// 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('/', createOpenApiRoutes());
app.route('/', createPrekeyRoutes(store, crypto, { events }));
app.route(
'/',
createPrekeyRoutes(store, crypto, {
events,
...(kt ? { keyTransparency: kt } : {}),
}),
);
app.route('/', createInboxRoutes(inboxStore, crypto, { events: inboxEvents }));
// V3.7 transport-bridge — SSE / long-poll / WS fallbacks for the inbox.
// Held as a top-level reference so the WebSocket handler can be passed to
// Bun.serve below.
const bridgeRoutes = createBridgeRoutes({
store: inboxStore,
crypto,
events: inboxEvents,
});
app.route('/', bridgeRoutes.app);
// ─── Optional: Observer + Dashboard ──────────────────────────
@@ -84,13 +209,31 @@ logger.info('Stale cleanup task started', {
intervalHours: Number(process.env.SHADE_CLEANUP_INTERVAL_HOURS ?? 24),
});
// ─── Inbox prune task ────────────────────────────────────────
const inboxPrune = new InboxPruneTask(inboxStore, {
events: inboxEvents,
logger: {
info: (m, d) => logger.info(m, d as Record<string, unknown> | undefined),
error: (m, d) => logger.error(m, d as Record<string, unknown> | undefined),
},
});
inboxPrune.start();
logger.info('Inbox prune task started', {
intervalMinutes: Number(process.env.SHADE_INBOX_PRUNE_INTERVAL_MINUTES ?? 5),
});
// ─── Start HTTP server ───────────────────────────────────────
const port = Number(process.env.PORT ?? 3900);
logger.info('Shade Prekey Server starting', { port, version: VERSION });
const server = Bun.serve({ port, fetch: app.fetch });
const server = Bun.serve({
port,
fetch: (req, srv) => app.fetch(req, srv),
websocket: bridgeRoutes.websocket as any,
});
// ─── Graceful shutdown ───────────────────────────────────────
@@ -102,10 +245,14 @@ async function shutdown(signal: string) {
try {
cleanupTask.stop();
inboxPrune.stop();
server.stop();
if ('close' in store && typeof store.close === 'function') {
await store.close();
}
if ('close' in inboxStore && typeof inboxStore.close === 'function') {
await inboxStore.close();
}
logger.info('Shutdown complete');
process.exit(0);
} catch (err) {