(Init): Added shit

This commit is contained in:
2026-05-28 23:46:40 +00:00
parent a5250706cb
commit 8410600c63
46 changed files with 3898 additions and 228 deletions

5
crates/bot/deno.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "@elly/bot",
"version": "0.1.0",
"exports": "./src/main.ts"
}

144
crates/bot/src/main.ts Normal file
View File

@@ -0,0 +1,144 @@
/**
* @elly/bot entrypoint — Phase 1 boot.
*
* Phase 1 responsibilities:
* 1. Load and validate `config.toml` against the shared Zod schema.
* 2. Load and validate bot environment variables (DISCORD_TOKEN, IPC_TOKEN).
* 3. Construct the root structured logger and emit a boot summary.
* 4. Wire SIGINT/SIGTERM handlers for graceful shutdown.
*
* Phase 3 will add: Discord.js client, command/interaction router, and
* the typed IPC client that talks to `@elly/core`.
*/
import {
BotEnvSchema,
ConfigError,
ConfigValidationError,
createLogger,
EnvValidationError,
loadConfig,
loadEnv,
type BotEnv,
type Config,
type Logger,
} from "@elly/shared";
const CONFIG_PATH = Deno.env.get("CONFIG_PATH") ?? "./config.toml";
const LOGGER_NAME = "@elly/bot";
async function main(): Promise<void> {
const config = await loadConfigOrExit();
const env = loadEnvOrExit();
const logger = buildLogger(config, env);
logger.info("phase 1 boot starting", {
crate: "bot",
nodeEnv: env.NODE_ENV,
configPath: CONFIG_PATH,
});
logBootSummary(logger, config, env);
installSignalHandlers(logger);
logger.info("phase 1 boot complete", {
pid: Deno.pid,
deno: Deno.version.deno,
typescript: Deno.version.typescript,
});
// Phase 1 has no long-running work; the Discord client arrives in Phase 3.
await logger.flush();
}
// =====================================================================
// Boot helpers
// =====================================================================
async function loadConfigOrExit(): Promise<Config> {
try {
return await loadConfig(CONFIG_PATH);
} catch (err) {
bootFail(err, "config");
}
}
function loadEnvOrExit(): BotEnv {
try {
return loadEnv(BotEnvSchema);
} catch (err) {
bootFail(err, "env");
}
}
function buildLogger(config: Config, env: BotEnv): Logger {
const level = env.LOG_LEVEL ?? config.logging.level;
const isProd = env.NODE_ENV === "production";
return createLogger({
name: LOGGER_NAME,
level,
format: isProd ? "json" : config.logging.format,
file: config.logging.file
? {
path: config.logging.file,
maxBytes: config.logging.file_max_bytes,
maxBackups: config.logging.file_max_backups,
}
: undefined,
});
}
function logBootSummary(logger: Logger, config: Config, env: BotEnv): void {
logger.info("config validated", {
bot: config.bot.name,
guild: config.guild.name,
coreIpc: `${config.ipc.host}:${config.ipc.port}`,
features: countEnabled(config.features),
});
logger.debug("environment validated", {
nodeEnv: env.NODE_ENV,
logLevel: env.LOG_LEVEL ?? "<from config>",
discordTokenPresent: Boolean(env.DISCORD_TOKEN),
ipcTokenPresent: Boolean(env.IPC_TOKEN),
});
}
function countEnabled(features: Record<string, boolean>): string {
const enabled = Object.entries(features)
.filter(([, v]) => v)
.map(([k]) => k);
return `${enabled.length}/${Object.keys(features).length} enabled`;
}
function installSignalHandlers(logger: Logger): void {
const shutdown = (signal: string) => {
logger.info("shutdown signal received", { signal });
logger.flush().finally(() => Deno.exit(0));
};
Deno.addSignalListener("SIGINT", () => shutdown("SIGINT"));
Deno.addSignalListener("SIGTERM", () => shutdown("SIGTERM"));
}
function bootFail(err: unknown, stage: "config" | "env"): never {
if (err instanceof ConfigValidationError || err instanceof EnvValidationError) {
console.error(`[@elly/bot] ${stage} validation failed:`);
for (const issue of err.issues) {
console.error(` - ${issue.path}: ${issue.message}`);
}
Deno.exit(1);
}
if (err instanceof ConfigError) {
console.error(`[@elly/bot] ${stage} error: ${err.message}`);
Deno.exit(1);
}
console.error(`[@elly/bot] unexpected ${stage} error:`, err);
Deno.exit(1);
}
if (import.meta.main) {
main().catch((err) => {
console.error("[@elly/bot] fatal boot error:", err);
Deno.exit(1);
});
}

5
crates/core/deno.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "@elly/core",
"version": "0.1.0",
"exports": "./src/main.ts"
}

View File

@@ -0,0 +1,148 @@
/**
* DI composition root for `@elly/core`.
*
* Owns the lifecycle of every infrastructure dependency in the right order:
*
* build(): open DB → run migrations → open Kv → build bus → build HTTP server
* shutdown(): http server → kv → db
*
* Every dependency is created exactly once and passed by reference to the
* consumers that need it. Nothing in this crate may reach for a module-level
* singleton; if you need a dependency, ask for it through the container.
*/
import type { Config, CoreEnv, Logger } from "@elly/shared";
import { openDatabase, type DbConnection } from "./infrastructure/db/connection.ts";
import { Migrator } from "./infrastructure/db/migrator.ts";
import { openKv, type KvHandle } from "./infrastructure/kv/store.ts";
import { CooldownStore } from "./infrastructure/kv/cooldown.ts";
import { InteractionStateStore } from "./infrastructure/kv/interactionState.ts";
import { CacheStore } from "./infrastructure/kv/cache.ts";
import { DomainEventBus } from "./infrastructure/pubsub/bus.ts";
import { createHttpServer, type HttpServer } from "./infrastructure/http/server.ts";
import { buildSystemRoutes } from "./infrastructure/http/routes/system.ts";
export interface CoreContainer {
readonly config: Config;
readonly env: CoreEnv;
readonly logger: Logger;
readonly version: string;
readonly startedAt: number;
readonly db: DbConnection;
readonly kv: KvHandle;
readonly cooldowns: CooldownStore;
readonly interactionState: InteractionStateStore;
readonly cache: CacheStore;
readonly bus: DomainEventBus;
readonly http: HttpServer;
/** Tear down every dependency in reverse-creation order. */
shutdown(): Promise<void>;
}
export interface BuildContainerOptions {
readonly config: Config;
readonly env: CoreEnv;
readonly logger: Logger;
readonly version: string;
}
export async function buildContainer(options: BuildContainerOptions): Promise<CoreContainer> {
const log = options.logger.child({ component: "container" });
const startedAt = Date.now();
log.debug("building core container");
// ---- Database -----------------------------------------------------
const db = await openDatabase({
path: options.config.database.path,
logger: options.logger,
});
const migrator = new Migrator({ db: db.db, logger: options.logger });
await migrator.migrateToLatest();
// ---- Kv + helpers -------------------------------------------------
const kv = await openKv({
path: options.config.kv.path,
logger: options.logger,
});
const cooldowns = new CooldownStore(kv);
const interactionState = new InteractionStateStore(kv);
const cache = new CacheStore(kv);
// ---- Event bus ----------------------------------------------------
const bus = new DomainEventBus(options.logger);
// ---- HTTP server --------------------------------------------------
const routes = buildSystemRoutes({
version: options.version,
startedAt,
db,
kv,
bus,
});
const http = createHttpServer({
host: options.config.ipc.host,
port: options.config.ipc.port,
ipcToken: options.env.IPC_TOKEN,
logger: options.logger,
bus,
routes,
});
log.info("core container built", {
db: options.config.database.path,
kv: options.config.kv.path,
ipc: `${options.config.ipc.host}:${options.config.ipc.port}`,
});
return {
config: options.config,
env: options.env,
logger: options.logger,
version: options.version,
startedAt,
db,
kv,
cooldowns,
interactionState,
cache,
bus,
http,
async shutdown(): Promise<void> {
const errors: unknown[] = [];
const tryClose = async (label: string, fn: () => Promise<void>) => {
try {
await fn();
} catch (err) {
log.error("shutdown step failed", {
step: label,
err: err instanceof Error ? err : new Error(String(err)),
});
errors.push(err);
}
};
// Reverse build order: stop accepting traffic first, then close DBs.
await tryClose("http", () => http.shutdown());
await tryClose("kv", () => kv.close());
await tryClose("db", () => db.close());
if (errors.length > 0) {
log.warn("core container shutdown completed with errors", {
errorCount: errors.length,
});
} else {
log.info("core container shutdown clean");
}
},
};
}

View File

@@ -0,0 +1,128 @@
/**
* SQLite connection bootstrap.
*
* Opens (or creates) the on-disk SQLite database, applies our production
* PRAGMAs, and wraps the connection in a fully-typed `Kysely<Database>`.
*
* Pragmas:
* - `journal_mode=WAL` — readers don't block writers; required for the
* IPC server + cron jobs running concurrently.
* - `foreign_keys=ON` — SQLite defaults to OFF; we want referential
* integrity for child tables added in Phase 4.
* - `busy_timeout=5000` — block writers up to 5s if WAL is being checkpointed.
* - `synchronous=NORMAL` — safe with WAL, much faster than FULL.
*/
import { Database as SqliteDatabase } from "@db/sqlite";
import { ensureDir } from "@std/fs";
import { dirname } from "@std/path";
import { Kysely, sql } from "kysely";
import type { Logger } from "@elly/shared";
import { DenoSqliteDialect } from "./kysely-dialect.ts";
import type { Database } from "./schema.ts";
export interface DbConnection {
/** Type-safe Kysely query builder bound to the schema. */
readonly db: Kysely<Database>;
/** Underlying FFI handle — exposed for raw maintenance ops (vacuum, backup). */
readonly raw: SqliteDatabase;
/** Absolute path the DB was opened from. */
readonly path: string;
/** Close the underlying connection and tear down Kysely. */
close(): Promise<void>;
/** Run `VACUUM` to compact the on-disk file. */
vacuum(): Promise<void>;
/** Copy the live database file to `targetPath` (safe under WAL). */
backup(targetPath: string): Promise<void>;
/** Return useful runtime stats (path, byte size, table count). */
getStats(): Promise<DbStats>;
}
export interface DbStats {
readonly path: string;
readonly sizeBytes: number;
readonly tables: ReadonlyArray<string>;
}
export interface OpenDbOptions {
readonly path: string;
readonly logger: Logger;
}
/**
* Open the SQLite file, apply pragmas, return the connection wrapper.
*
* Idempotent across crash/restart: WAL files left behind are recovered
* automatically by SQLite on the next open.
*/
export async function openDatabase(options: OpenDbOptions): Promise<DbConnection> {
const log = options.logger.child({ component: "db" });
await ensureDir(dirname(options.path));
log.debug("opening sqlite database", { path: options.path });
const raw = new SqliteDatabase(options.path);
// Apply pragmas. `db.exec()` is sync; failures throw synchronously.
raw.exec("PRAGMA journal_mode = WAL");
raw.exec("PRAGMA foreign_keys = ON");
raw.exec("PRAGMA synchronous = NORMAL");
raw.exec("PRAGMA busy_timeout = 5000");
const db = new Kysely<Database>({ dialect: new DenoSqliteDialect(raw) });
log.info("sqlite database opened", { path: options.path });
return {
db,
raw,
path: options.path,
async close(): Promise<void> {
log.debug("closing sqlite database");
await db.destroy();
// Kysely's destroy() calls driver.destroy(), which closes `raw`.
},
async vacuum(): Promise<void> {
log.debug("vacuuming sqlite database");
await sql`VACUUM`.execute(db);
},
async backup(targetPath: string): Promise<void> {
log.info("backing up sqlite database", { target: targetPath });
await ensureDir(dirname(targetPath));
// `VACUUM INTO` is the SQLite-recommended online-safe backup method.
await sql`VACUUM INTO ${sql.lit(targetPath)}`.execute(db);
},
getStats(): Promise<DbStats> {
return collectStats(db, options.path);
},
};
}
async function collectStats(
db: Kysely<Database>,
path: string,
): Promise<DbStats> {
let sizeBytes = 0;
try {
const stat = await Deno.stat(path);
sizeBytes = stat.size;
} catch {
sizeBytes = 0;
}
const result = await sql<{ name: string }>`
SELECT name FROM sqlite_master
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
ORDER BY name
`.execute(db);
return {
path,
sizeBytes,
tables: result.rows.map((r) => r.name),
};
}

View File

@@ -0,0 +1,144 @@
/**
* Custom Kysely dialect that drives `jsr:@db/sqlite` (native FFI SQLite).
*
* Kysely's bundled `SqliteDialect` depends on `better-sqlite3`, which is
* Node-only. This thin adapter wraps Deno's FFI binding so we keep query
* builder, types, and migrations entirely in the Kysely ecosystem without
* pulling in a Node compatibility shim.
*
* Notes:
* - All operations are sync at the FFI layer; we wrap in `Promise.resolve`
* to satisfy Kysely's async `DatabaseConnection` contract.
* - Read-vs-write dispatch uses the compiled query's AST kind, falling
* back to a SQL prefix check for raw queries.
* - Streaming is not supported (FFI driver returns full result sets).
*/
import {
CompiledQuery,
type DatabaseConnection,
type DatabaseIntrospector,
type Dialect,
type DialectAdapter,
type Driver,
type Kysely,
type QueryCompiler,
type QueryResult,
SqliteAdapter,
SqliteIntrospector,
SqliteQueryCompiler,
} from "kysely";
import type { BindValue, Database as SqliteDatabase } from "@db/sqlite";
export class DenoSqliteDialect implements Dialect {
constructor(private readonly db: SqliteDatabase) {}
createAdapter(): DialectAdapter {
return new SqliteAdapter();
}
createDriver(): Driver {
return new DenoSqliteDriver(this.db);
}
createIntrospector(db: Kysely<unknown>): DatabaseIntrospector {
return new SqliteIntrospector(db);
}
createQueryCompiler(): QueryCompiler {
return new SqliteQueryCompiler();
}
}
class DenoSqliteDriver implements Driver {
constructor(private readonly db: SqliteDatabase) {}
init(): Promise<void> {
return Promise.resolve();
}
acquireConnection(): Promise<DatabaseConnection> {
return Promise.resolve(new DenoSqliteConnection(this.db));
}
async beginTransaction(conn: DatabaseConnection): Promise<void> {
await conn.executeQuery(CompiledQuery.raw("BEGIN"));
}
async commitTransaction(conn: DatabaseConnection): Promise<void> {
await conn.executeQuery(CompiledQuery.raw("COMMIT"));
}
async rollbackTransaction(conn: DatabaseConnection): Promise<void> {
await conn.executeQuery(CompiledQuery.raw("ROLLBACK"));
}
releaseConnection(): Promise<void> {
// FFI driver shares one connection — nothing to release per-statement.
return Promise.resolve();
}
destroy(): Promise<void> {
try {
this.db.close();
} catch {
// Closing a never-opened or already-closed DB is a no-op for us.
}
return Promise.resolve();
}
}
class DenoSqliteConnection implements DatabaseConnection {
constructor(private readonly db: SqliteDatabase) {}
executeQuery<R>(query: CompiledQuery): Promise<QueryResult<R>> {
// Kysely produces parameter values that are already SQLite-bindable
// (numbers, strings, BigInts, booleans, null, Uint8Array). The cast
// crosses the `unknown` -> `BindValue` boundary without inspecting each
// element; misuse would surface as a runtime SQLite error.
const params = query.parameters as readonly BindValue[];
const stmt = this.db.prepare(query.sql);
try {
if (isReadQuery(query)) {
const rows = stmt.all(...params) as R[];
return Promise.resolve({ rows });
}
const changes = stmt.run(...params);
// `@db/sqlite` exposes the most recent insert rowid on the Database
// instance after a successful `.run()`. Only surface it when SQLite
// actually assigned a row (rowid > 0).
const lastId = this.db.lastInsertRowId;
return Promise.resolve({
rows: [],
numAffectedRows: BigInt(changes),
insertId: lastId !== undefined && Number(lastId) > 0 ? BigInt(lastId) : undefined,
});
} finally {
stmt.finalize();
}
}
// deno-lint-ignore require-yield
async *streamQuery<R>(_query: CompiledQuery): AsyncIterableIterator<QueryResult<R>> {
throw new Error("Streaming is not supported by DenoSqliteDialect");
}
}
function isReadQuery(query: CompiledQuery): boolean {
const kind = (query.query as { kind?: string }).kind;
switch (kind) {
case "SelectQueryNode":
case "WithNode":
return true;
}
// RETURNING clauses on INSERT/UPDATE/DELETE produce rows. Kysely's
// `RootOperationNode` is a tagged union with no string index signature,
// so we route through `unknown` before the structural property probe.
const node = query.query as unknown as Record<string, unknown>;
if ("returning" in node && node.returning != null) return true;
// Raw/unknown — fall back to a prefix check.
return /^\s*(SELECT|WITH|PRAGMA|EXPLAIN)\b/i.test(query.sql);
}

View File

@@ -0,0 +1,26 @@
/**
* Migration 0001 — initial schema bookkeeping.
*
* Creates the `schema_migrations` table used by the migrator to track which
* versions have been applied. Domain tables are introduced in subsequent
* migrations once Phase 4 starts adding feature schemas.
*/
import type { Kysely } from "kysely";
export const version = 1;
export const name = "0001_initial";
export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema
.createTable("schema_migrations")
.ifNotExists()
.addColumn("version", "integer", (col) => col.primaryKey())
.addColumn("name", "text", (col) => col.notNull())
.addColumn("applied_at", "text", (col) => col.notNull())
.execute();
}
export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.dropTable("schema_migrations").ifExists().execute();
}

View File

@@ -0,0 +1,27 @@
/**
* Ordered registry of all migrations.
*
* Each migration module exports `version`, `name`, `up(db)`, and `down(db)`.
* The `Migrator` applies any version > the highest one recorded in the
* `schema_migrations` table, in ascending order.
*
* New migrations are added by:
* 1. Creating `NNNN_description.ts` in this directory.
* 2. Importing it here and appending to the `MIGRATIONS` array.
* 3. Updating `Database` in `../schema.ts` to reflect the new columns.
*/
import type { Kysely } from "kysely";
export interface Migration {
readonly version: number;
readonly name: string;
up(db: Kysely<unknown>): Promise<void>;
down(db: Kysely<unknown>): Promise<void>;
}
import * as m0001 from "./0001_initial.ts";
export const MIGRATIONS: ReadonlyArray<Migration> = [
m0001,
] as const;

View File

@@ -0,0 +1,141 @@
/**
* Forward-only migration runner.
*
* Reads `MIGRATIONS` (ordered by version), determines the current schema
* version from `schema_migrations`, and applies any newer migrations in a
* single transaction each. Failure inside an `up()` rolls that one back —
* earlier migrations remain applied.
*
* Phase 2 ships only one migration (bookkeeping). The migrator is built now
* so Phase 4 can add domain-table migrations without changing infrastructure.
*/
import type { Kysely } from "kysely";
import { sql } from "kysely";
import type { Logger } from "@elly/shared";
import type { Database } from "./schema.ts";
import { type Migration, MIGRATIONS } from "./migrations/index.ts";
export interface MigratorOptions {
readonly db: Kysely<Database>;
readonly logger: Logger;
/** Override the migration list — useful for ad-hoc tooling. */
readonly migrations?: ReadonlyArray<Migration>;
}
export interface MigratorRunSummary {
readonly applied: ReadonlyArray<{ version: number; name: string }>;
readonly skipped: ReadonlyArray<{ version: number; name: string }>;
readonly finalVersion: number;
}
export class Migrator {
private readonly db: Kysely<Database>;
private readonly log: Logger;
private readonly migrations: ReadonlyArray<Migration>;
constructor(options: MigratorOptions) {
this.db = options.db;
this.log = options.logger.child({ component: "migrator" });
this.migrations = (options.migrations ?? MIGRATIONS)
.slice()
.sort((a, b) => a.version - b.version);
this.assertVersionsUnique();
}
/** Apply every migration newer than the recorded version. */
async migrateToLatest(): Promise<MigratorRunSummary> {
await this.ensureBootstrapTable();
const currentVersion = await this.currentVersion();
this.log.debug("migration baseline", { currentVersion });
const applied: Array<{ version: number; name: string }> = [];
const skipped: Array<{ version: number; name: string }> = [];
for (const migration of this.migrations) {
if (migration.version <= currentVersion) {
skipped.push({ version: migration.version, name: migration.name });
continue;
}
await this.apply(migration);
applied.push({ version: migration.version, name: migration.name });
}
const finalVersion = await this.currentVersion();
if (applied.length > 0) {
this.log.info("migrations applied", {
count: applied.length,
finalVersion,
names: applied.map((a) => a.name),
});
} else {
this.log.debug("schema up to date", { finalVersion });
}
return { applied, skipped, finalVersion };
}
/** Return the highest migration version recorded, or 0 if none. */
async currentVersion(): Promise<number> {
const result = await sql<{ v: number | null }>`
SELECT MAX(version) AS v FROM schema_migrations
`.execute(this.db);
const row = result.rows[0];
return row?.v ?? 0;
}
private async ensureBootstrapTable(): Promise<void> {
// The very first migration creates `schema_migrations`, but we need
// the table to exist BEFORE we read from it. Create it idempotently here.
await this.db.schema
.createTable("schema_migrations")
.ifNotExists()
.addColumn("version", "integer", (col) => col.primaryKey())
.addColumn("name", "text", (col) => col.notNull())
.addColumn("applied_at", "text", (col) => col.notNull())
.execute();
}
private async apply(migration: Migration): Promise<void> {
const startedAt = performance.now();
this.log.info("applying migration", {
version: migration.version,
name: migration.name,
});
await this.db.transaction().execute(async (trx) => {
// Migrations operate on the bare DDL surface; `Kysely<unknown>` is
// the standard variance escape hatch since at apply-time the
// typed `Database` may not yet reflect the schema being created.
await migration.up(trx as unknown as Kysely<unknown>);
await trx
.insertInto("schema_migrations")
.values({
version: migration.version,
name: migration.name,
applied_at: new Date().toISOString(),
})
.execute();
});
const durationMs = Number((performance.now() - startedAt).toFixed(2));
this.log.info("migration applied", {
version: migration.version,
name: migration.name,
durationMs,
});
}
private assertVersionsUnique(): void {
const seen = new Set<number>();
for (const m of this.migrations) {
if (seen.has(m.version)) {
throw new Error(
`Duplicate migration version ${m.version}; check crates/core/src/infrastructure/db/migrations/index.ts`,
);
}
seen.add(m.version);
}
}
}

View File

@@ -0,0 +1,45 @@
/**
* Kysely `Database` schema — the canonical TypeScript description of every
* table the core crate persists into SQLite.
*
* Phase 2 only declares the bookkeeping table required by the migrator
* (`schema_migrations`). Each subsequent migration that introduces a new
* domain table also extends this interface here so the type-checker prevents
* us from referencing columns that don't exist.
*
* Convention:
* - Surrogate IDs use ULIDs (string) generated by `@std/ulid`.
* - Timestamps are stored as ISO 8601 strings (TEXT) — easy to read in
* `sqlite3` CLI and trivially sortable.
* - Booleans are stored as 0/1 INTEGERs (SQLite has no native bool).
*/
import type { Generated } from "kysely";
// =====================================================================
// schema_migrations — managed by `Migrator`
// =====================================================================
export interface SchemaMigrationsTable {
/** Monotonically increasing migration version, e.g. 1, 2, 3. */
version: number;
/** Human-readable name, e.g. `"0001_initial"`. */
name: string;
/** ISO timestamp of when the migration was applied. */
applied_at: string;
}
// =====================================================================
// Composite Database type — passed to `Kysely<Database>`
// =====================================================================
export interface Database {
schema_migrations: SchemaMigrationsTable;
}
// =====================================================================
// Helper aliases for migrations
// =====================================================================
export type GeneratedId = Generated<string>;
export type Timestamp = string;

View File

@@ -0,0 +1,76 @@
/**
* Bearer-token authentication middleware.
*
* Compares the `Authorization: Bearer <token>` header against the configured
* `IPC_TOKEN`. The comparison is constant-time to avoid leaking the token
* via timing side-channels on the loopback interface.
*
* Routes flagged `anonymous: true` (e.g. `/health`) are exempted. The match
* decision is provided to this middleware via `ctx.locals.routeAnonymous`,
* which the server sets after route resolution.
*/
import { IPC_AUTH_HEADER, IpcErrorCode } from "@elly/shared";
import type { HttpMiddleware } from "../types.ts";
import { jsonError } from "./error.ts";
const BEARER_PREFIX = "Bearer ";
export interface AuthMiddlewareOptions {
readonly token: string;
}
export function authMiddleware(options: AuthMiddlewareOptions): HttpMiddleware {
const expected = options.token;
if (expected.length === 0) {
throw new Error("authMiddleware requires a non-empty IPC token");
}
return (ctx, next): Promise<Response> => {
if (ctx.locals.routeAnonymous === true) {
return next();
}
const header = ctx.request.headers.get(IPC_AUTH_HEADER);
if (!header || !header.startsWith(BEARER_PREFIX)) {
ctx.logger.warn("ipc auth missing", { reason: "no_bearer_header" });
return Promise.resolve(
jsonError({
status: 401,
code: IpcErrorCode.UNAUTHORIZED,
message: "Missing or malformed Authorization header",
requestId: ctx.requestId,
}),
);
}
const presented = header.slice(BEARER_PREFIX.length);
if (!timingSafeEqual(presented, expected)) {
ctx.logger.warn("ipc auth failed", { reason: "token_mismatch" });
return Promise.resolve(
jsonError({
status: 401,
code: IpcErrorCode.UNAUTHORIZED,
message: "Invalid IPC token",
requestId: ctx.requestId,
}),
);
}
return next();
};
}
function timingSafeEqual(a: string, b: string): boolean {
// Constant-time equality. We compare to the longer of the two so timing
// doesn't leak length. Diff bits accumulate into `mismatch`.
const ab = new TextEncoder().encode(a);
const bb = new TextEncoder().encode(b);
const len = Math.max(ab.length, bb.length);
let mismatch = ab.length ^ bb.length;
for (let i = 0; i < len; i++) {
mismatch |= (ab[i] ?? 0) ^ (bb[i] ?? 0);
}
return mismatch === 0;
}

View File

@@ -0,0 +1,97 @@
/**
* Error-handling middleware + JSON error helper.
*
* The middleware wraps the entire request chain. Any uncaught exception is
* captured, logged with its stack, and serialized to the canonical
* `IpcErrorBody` envelope (`@elly/shared`). Known `IpcError` instances
* preserve their `code`/`status`/`requestId`; everything else is rendered
* as a generic 500 to avoid leaking internals.
*/
import { IPC_ERROR_STATUS, IpcError, IpcErrorCode } from "@elly/shared";
import type { IpcErrorBody } from "@elly/shared";
import type { HttpMiddleware } from "../types.ts";
export function errorMiddleware(): HttpMiddleware {
return async (ctx, next) => {
try {
return await next();
} catch (err) {
if (err instanceof IpcError) {
ctx.logger.warn("ipc handler threw IpcError", {
code: err.code,
status: err.status,
err: { name: err.name, message: err.message },
});
return jsonError({
status: err.status,
code: err.code,
message: err.message,
requestId: ctx.requestId,
details: err.details,
});
}
const wrapped = err instanceof Error ? err : new Error(String(err));
ctx.logger.error("ipc handler threw unexpectedly", {
err: wrapped,
});
return jsonError({
status: 500,
code: IpcErrorCode.INTERNAL,
message: "Internal core error",
requestId: ctx.requestId,
});
}
};
}
export interface JsonErrorInput {
readonly status: number;
readonly code: string;
readonly message: string;
readonly requestId?: string;
readonly details?: unknown;
}
/**
* Build a `Response` carrying the canonical `IpcErrorBody` envelope.
*
* The status falls back to `IPC_ERROR_STATUS[code]` when the caller passes
* `status: 0`, allowing handlers to specify only the semantic code.
*/
export function jsonError(input: JsonErrorInput): Response {
const status = input.status > 0
? input.status
: (IPC_ERROR_STATUS as Record<string, number>)[input.code] ?? 500;
const body: IpcErrorBody = {
error: {
code: input.code,
message: input.message,
requestId: input.requestId,
details: input.details,
},
};
return new Response(JSON.stringify(body), {
status,
headers: {
"content-type": "application/json; charset=utf-8",
...(input.requestId ? { "x-request-id": input.requestId } : {}),
},
});
}
/**
* Convenience for handlers: build a success JSON response.
*/
export function jsonOk(body: unknown, init: { status?: number; requestId?: string } = {}): Response {
return new Response(JSON.stringify(body), {
status: init.status ?? 200,
headers: {
"content-type": "application/json; charset=utf-8",
...(init.requestId ? { "x-request-id": init.requestId } : {}),
},
});
}

View File

@@ -0,0 +1,45 @@
/**
* Request/response logging middleware.
*
* Emits two structured log lines per request:
* - `info` `ipc request started` — method, path, requestId
* - `info` `ipc request completed` — same fields + status, durationMs
*
* Failures (rejected promise inside `next`) are re-thrown after logging
* so the error middleware downstream can format them into envelopes.
*
* Per the architectural mandate, every request's latency is recorded —
* this is the foundation for production SLO dashboards in later phases.
*/
import type { HttpMiddleware } from "../types.ts";
export function loggingMiddleware(): HttpMiddleware {
return async (ctx, next) => {
ctx.logger.info("ipc request started", {
method: ctx.method,
path: ctx.url.pathname,
});
try {
const response = await next();
const durationMs = Number((performance.now() - ctx.startedAtMs).toFixed(2));
ctx.logger.info("ipc request completed", {
method: ctx.method,
path: ctx.url.pathname,
status: response.status,
durationMs,
});
return response;
} catch (err) {
const durationMs = Number((performance.now() - ctx.startedAtMs).toFixed(2));
ctx.logger.error("ipc request failed", {
method: ctx.method,
path: ctx.url.pathname,
durationMs,
err: err instanceof Error ? err : new Error(String(err)),
});
throw err;
}
};
}

View File

@@ -0,0 +1,147 @@
/**
* Minimal dependency-free HTTP router.
*
* Compiles `:param` segments in path patterns into RegExp at registration
* time so dispatch is O(routes) per request. Returns:
* - The matching route plus extracted `params`, or
* - A 404 sentinel handler if no route matches the path, or
* - A 405 sentinel handler if the path matched but no method matched.
*
* Middleware compose left-to-right; the final layer is the route handler.
*/
import type {
HttpContext,
HttpHandler,
HttpMethod,
HttpHandler as Handler,
HttpMiddleware,
RouteDefinition,
} from "./types.ts";
interface CompiledRoute {
readonly method: HttpMethod;
readonly pattern: RegExp;
readonly paramNames: ReadonlyArray<string>;
readonly anonymous: boolean;
readonly handler: Handler;
readonly rawPath: string;
}
export interface RouteMatch {
readonly handler: HttpHandler;
readonly params: Readonly<Record<string, string>>;
readonly anonymous: boolean;
readonly status: 200 | 404 | 405;
}
export class HttpRouter {
private readonly routes: CompiledRoute[] = [];
private readonly middleware: HttpMiddleware[] = [];
use(mw: HttpMiddleware): void {
this.middleware.push(mw);
}
register(route: RouteDefinition): void {
this.routes.push(compile(route));
}
registerMany(routes: ReadonlyArray<RouteDefinition>): void {
for (const r of routes) this.register(r);
}
/** Find the best match for `(method, path)`. */
resolve(method: HttpMethod, path: string): RouteMatch {
let methodMismatch = false;
for (const route of this.routes) {
const m = route.pattern.exec(path);
if (!m) continue;
if (route.method !== method) {
methodMismatch = true;
continue;
}
const params: Record<string, string> = {};
for (let i = 0; i < route.paramNames.length; i++) {
params[route.paramNames[i]] = decodeURIComponent(m[i + 1] ?? "");
}
return {
handler: route.handler,
params,
anonymous: route.anonymous,
status: 200,
};
}
return {
handler: methodMismatch ? methodNotAllowedHandler : notFoundHandler,
params: {},
anonymous: true,
status: methodMismatch ? 405 : 404,
};
}
/**
* Run the middleware chain followed by the matched handler.
* `match.handler` is the final layer.
*/
dispatch(ctx: HttpContext, match: RouteMatch): Promise<Response> {
let index = -1;
const chain = this.middleware;
const next = (): Promise<Response> => {
index++;
if (index < chain.length) {
return chain[index](ctx, next);
}
return Promise.resolve(match.handler(ctx));
};
return next();
}
}
// =====================================================================
// Helpers
// =====================================================================
function compile(route: RouteDefinition): CompiledRoute {
const paramNames: string[] = [];
// Escape literal characters except `:param` segments.
const regexBody = route.path
.split("/")
.map((segment) => {
if (segment.startsWith(":")) {
paramNames.push(segment.slice(1));
return "([^/]+)";
}
return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
})
.join("/");
return {
method: route.method,
pattern: new RegExp(`^${regexBody}$`),
paramNames,
anonymous: route.anonymous ?? false,
handler: route.handler,
rawPath: route.path,
};
}
function notFoundHandler(): Response {
return new Response(
JSON.stringify({
error: { code: "not_found", message: "Route not found" },
}),
{ status: 404, headers: { "content-type": "application/json" } },
);
}
function methodNotAllowedHandler(): Response {
return new Response(
JSON.stringify({
error: { code: "method_not_allowed", message: "Method not allowed for this route" },
}),
{ status: 405, headers: { "content-type": "application/json" } },
);
}

View File

@@ -0,0 +1,148 @@
/**
* Phase 2 system routes:
* - `GET /health` Anonymous liveness probe (200 OK).
* - `GET /v1/version` Authenticated diagnostic payload.
* - `GET /v1/events` Authenticated SSE stream of domain events.
*
* These are the only routes Phase 2 ships. Phase 4 will add the feature
* routes (`/v1/applications/...`, `/v1/suggestions/...`, etc.).
*/
import { IpcRoutes } from "@elly/shared";
import type { AnyDomainEvent } from "@elly/shared";
import type { DbConnection } from "../../db/connection.ts";
import type { KvHandle } from "../../kv/store.ts";
import type { DomainEventBus } from "../../pubsub/bus.ts";
import { jsonOk } from "../middleware/error.ts";
import type { HttpContext, RouteDefinition } from "../types.ts";
export interface SystemRouteDeps {
readonly version: string;
readonly startedAt: number;
readonly db: DbConnection;
readonly kv: KvHandle;
readonly bus: DomainEventBus;
}
const SSE_HEADERS: HeadersInit = {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
"connection": "keep-alive",
// The bot's SSE consumer (Phase 3) doesn't use `x-accel-buffering`, but
// operators sitting behind nginx will thank us.
"x-accel-buffering": "no",
};
export function buildSystemRoutes(deps: SystemRouteDeps): ReadonlyArray<RouteDefinition> {
return [
{
method: "GET",
path: IpcRoutes.HEALTH,
anonymous: true,
handler: (ctx) =>
jsonOk(
{ status: "ok", uptimeMs: Date.now() - deps.startedAt },
{ requestId: ctx.requestId },
),
},
{
method: "GET",
path: IpcRoutes.VERSION,
handler: async (ctx) => {
const stats = await deps.db.getStats();
return jsonOk(
{
version: deps.version,
pid: Deno.pid,
uptimeMs: Date.now() - deps.startedAt,
deno: Deno.version,
db: {
path: stats.path,
sizeBytes: stats.sizeBytes,
tables: stats.tables,
},
kv: {
path: deps.kv.path,
},
bus: {
subscribers: deps.bus.subscribers,
},
},
{ requestId: ctx.requestId },
);
},
},
{
method: "GET",
path: IpcRoutes.EVENTS,
handler: (ctx) => buildSseResponse(ctx, deps.bus),
},
];
}
function buildSseResponse(ctx: HttpContext, bus: DomainEventBus): Response {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
let closed = false;
const closeIfOpen = () => {
if (closed) return;
closed = true;
try {
controller.close();
} catch {
// already closed
}
};
const tryEnqueue = (chunk: Uint8Array) => {
if (closed) return;
try {
controller.enqueue(chunk);
} catch {
closed = true;
}
};
// Initial comment so proxies open the connection right away.
tryEnqueue(encoder.encode(`: connected requestId=${ctx.requestId}\n\n`));
// Replay nothing — Phase 2 has no backlog. Subscribe live.
const unsubscribe = bus.subscribe((event: AnyDomainEvent) => {
const payload = `event: ${event.type}\nid: ${event.id}\ndata: ${
JSON.stringify(event)
}\n\n`;
tryEnqueue(encoder.encode(payload));
});
// Periodic comment-line keep-alive so idle connections aren't reaped
// by intermediaries that close on inactivity.
const heartbeat = setInterval(() => {
tryEnqueue(encoder.encode(`: keep-alive ${Date.now()}\n\n`));
}, 15_000);
const onAbort = () => {
ctx.logger.debug("sse client disconnected", { requestId: ctx.requestId });
clearInterval(heartbeat);
unsubscribe();
closeIfOpen();
};
if (ctx.signal.aborted) {
onAbort();
} else {
ctx.signal.addEventListener("abort", onAbort, { once: true });
}
ctx.logger.info("sse client connected", { requestId: ctx.requestId });
},
});
return new Response(stream, {
status: 200,
headers: { ...SSE_HEADERS, "x-request-id": ctx.requestId },
});
}

View File

@@ -0,0 +1,167 @@
/**
* Core IPC HTTP server.
*
* Wires together:
* - `HttpRouter` — route resolution + middleware composition.
* - Middleware chain (in onion order, outer → inner):
* errorMiddleware → loggingMiddleware → authMiddleware → handler
* - `Deno.serve` — Deno's native server, bound to `host:port` from config.
*
* Exposes `start()` (returns when the server is accepting connections) and
* `shutdown()` (graceful drain with a hard timeout).
*/
import { ulid } from "@std/ulid";
import { IPC_REQUEST_ID_HEADER } from "@elly/shared";
import type { Logger } from "@elly/shared";
import type { DomainEventBus } from "../pubsub/bus.ts";
import { authMiddleware } from "./middleware/auth.ts";
import { errorMiddleware } from "./middleware/error.ts";
import { loggingMiddleware } from "./middleware/logging.ts";
import { HttpRouter } from "./router.ts";
import type {
HttpContext,
HttpMethod,
RouteDefinition,
} from "./types.ts";
export interface HttpServerOptions {
readonly host: string;
readonly port: number;
readonly ipcToken: string;
readonly logger: Logger;
readonly bus: DomainEventBus;
readonly routes: ReadonlyArray<RouteDefinition>;
/** Hard timeout (ms) for shutdown to wait for in-flight requests. */
readonly shutdownTimeoutMs?: number;
}
export interface HttpServer {
readonly host: string;
readonly port: number;
start(): Promise<void>;
shutdown(): Promise<void>;
}
export function createHttpServer(options: HttpServerOptions): HttpServer {
const log = options.logger.child({ component: "http" });
const router = new HttpRouter();
// Outer-most middleware first.
router.use(errorMiddleware());
router.use(loggingMiddleware());
router.use(authMiddleware({ token: options.ipcToken }));
router.registerMany(options.routes);
const controller = new AbortController();
let server: Deno.HttpServer | null = null;
let listening: Promise<void> | null = null;
return {
host: options.host,
port: options.port,
start(): Promise<void> {
if (listening) return listening;
listening = new Promise<void>((resolve, reject) => {
try {
server = Deno.serve(
{
hostname: options.host,
port: options.port,
signal: controller.signal,
onListen: ({ hostname, port }) => {
log.info("ipc http server listening", { hostname, port });
resolve();
},
onError: (err) => {
const wrapped = err instanceof Error ? err : new Error(String(err));
log.error("ipc http unhandled error", { err: wrapped });
return new Response("internal", { status: 500 });
},
},
(request) => handleRequest(request, router, options.bus, log),
);
} catch (err) {
reject(err);
}
});
return listening;
},
async shutdown(): Promise<void> {
log.info("ipc http server shutting down");
controller.abort();
if (server) {
const timeoutMs = options.shutdownTimeoutMs ?? 5_000;
await raceWithTimeout(server.finished, timeoutMs, () => {
log.warn("ipc http server shutdown timed out; forcing", { timeoutMs });
});
}
log.info("ipc http server stopped");
},
};
}
function handleRequest(
request: Request,
router: HttpRouter,
bus: DomainEventBus,
logger: Logger,
): Promise<Response> {
const url = new URL(request.url);
const method = request.method.toUpperCase() as HttpMethod;
const requestId = request.headers.get(IPC_REQUEST_ID_HEADER) ?? ulid();
const startedAtMs = performance.now();
const match = router.resolve(method, url.pathname);
const locals: Record<string, unknown> = {
routeAnonymous: match.anonymous,
routeStatus: match.status,
};
const requestLogger = logger.child({
requestId,
method,
path: url.pathname,
});
const ctx: HttpContext = {
request,
url,
method,
params: match.params,
requestId,
startedAtMs,
logger: requestLogger,
bus,
locals,
signal: request.signal,
};
return router.dispatch(ctx, match);
}
async function raceWithTimeout(
promise: Promise<void>,
timeoutMs: number,
onTimeout: () => void,
): Promise<void> {
let timer: number | undefined;
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(() => {
onTimeout();
resolve();
}, timeoutMs);
});
try {
await Promise.race([promise, timeout]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}

View File

@@ -0,0 +1,53 @@
/**
* Shared types for the IPC HTTP layer.
*
* The router is intentionally tiny and dependency-free — Phase 2 doesn't
* justify a framework. Middleware compose around handlers via a classic
* onion model: each middleware receives `(ctx, next)` and may short-circuit
* by returning a response without calling `next`.
*/
import type { Logger } from "@elly/shared";
import type { DomainEventBus } from "../pubsub/bus.ts";
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
export interface HttpContext {
/** Inbound request. */
readonly request: Request;
/** Parsed URL — cached so middleware/handlers don't re-parse. */
readonly url: URL;
/** Method (uppercased). */
readonly method: HttpMethod;
/** Path params extracted by the router (empty for no-param routes). */
readonly params: Readonly<Record<string, string>>;
/** Correlation ID — generated or echoed from `x-request-id`. */
readonly requestId: string;
/** Wall-clock start of the request in ms (for latency logging). */
readonly startedAtMs: number;
/** Logger pre-bound with method, path, requestId. */
readonly logger: Logger;
/** Domain event bus (handlers may publish from here). */
readonly bus: DomainEventBus;
/** Mutable bag for cross-middleware state (rarely used). */
readonly locals: Record<string, unknown>;
/** Convenience accessor for the connection abort signal. */
readonly signal: AbortSignal;
}
export type HttpHandler = (ctx: HttpContext) => Promise<Response> | Response;
export type HttpMiddleware = (
ctx: HttpContext,
next: () => Promise<Response>,
) => Promise<Response>;
export interface RouteDefinition {
readonly method: HttpMethod;
/** Path pattern, e.g. `"/v1/applications/:id"`. Compiled by the router. */
readonly path: string;
/** Optional: marks a route as unauthenticated (default: requires auth). */
readonly anonymous?: boolean;
readonly handler: HttpHandler;
}

View File

@@ -0,0 +1,85 @@
/**
* Generic TTL cache backed by `Deno.Kv`.
*
* Used in Phase 4 by the PikaNetwork client to cache profile/leaderboard
* lookups across restarts. Each entry carries an explicit `cachedAt` so
* callers can detect "stale-but-acceptable" reads.
*
* Keys are namespaced by `cacheKind` (e.g. `"pika:profile"`) so unrelated
* features cannot collide. The actual KV key is
* `[ "cache", cacheKind, ...identifier ]`.
*/
import type { KvHandle } from "./store.ts";
export interface CacheEntry<T> {
readonly value: T;
readonly cachedAt: number;
readonly expiresAt: number;
}
export interface CacheGetResult<T> {
readonly hit: boolean;
readonly entry: CacheEntry<T> | null;
}
export class CacheStore {
constructor(private readonly handle: KvHandle) {}
async get<T>(cacheKind: string, identifier: Deno.KvKey): Promise<CacheGetResult<T>> {
const key = this.buildKey(cacheKind, identifier);
const entry = await this.handle.kv.get<CacheEntry<T>>(key);
if (entry.value === null) {
return { hit: false, entry: null };
}
if (entry.value.expiresAt <= Date.now()) {
// Best-effort cleanup of an expired entry that Kv hasn't reaped yet.
await this.handle.kv.delete(key);
return { hit: false, entry: null };
}
return { hit: true, entry: entry.value };
}
async set<T>(args: {
cacheKind: string;
identifier: Deno.KvKey;
value: T;
ttlMs: number;
}): Promise<CacheEntry<T>> {
if (args.ttlMs <= 0) {
throw new Error("CacheStore.set requires ttlMs > 0");
}
const now = Date.now();
const entry: CacheEntry<T> = {
value: args.value,
cachedAt: now,
expiresAt: now + args.ttlMs,
};
const key = this.buildKey(args.cacheKind, args.identifier);
await this.handle.kv.set(key, entry, { expireIn: args.ttlMs });
return entry;
}
async delete(cacheKind: string, identifier: Deno.KvKey): Promise<void> {
const key = this.buildKey(cacheKind, identifier);
await this.handle.kv.delete(key);
}
/**
* Drop every entry for a given `cacheKind`. Use sparingly — full prefix
* scans are O(n) over that kind.
*/
async deleteKind(cacheKind: string): Promise<number> {
const prefix: Deno.KvKey = ["cache", cacheKind];
let count = 0;
for await (const entry of this.handle.kv.list({ prefix })) {
await this.handle.kv.delete(entry.key);
count++;
}
return count;
}
private buildKey(cacheKind: string, identifier: Deno.KvKey): Deno.KvKey {
return this.handle.key("cache", cacheKind, ...identifier);
}
}

View File

@@ -0,0 +1,93 @@
/**
* Cooldown store backed by `Deno.Kv`.
*
* Survives process restarts (unlike the legacy in-memory `Collection` map).
* Each entry is keyed by `[ "cooldown", command, userId ]` and stores the
* epoch-ms timestamp at which the cooldown expires. We use Kv's `expireIn`
* to let the runtime garbage-collect expired entries.
*
* `checkAndSet` is atomic via Kv's compare-and-swap loop — concurrent
* invocations cannot both observe "no cooldown" and both set one.
*/
import type { KvHandle } from "./store.ts";
export interface CooldownCheckResult {
/** True if the caller is currently rate-limited. */
readonly limited: boolean;
/** Seconds remaining before the cooldown expires (rounded up). 0 if not limited. */
readonly retryAfterSeconds: number;
/** Underlying epoch-ms expiry; 0 if not limited. */
readonly expiresAtMs: number;
}
export class CooldownStore {
constructor(private readonly handle: KvHandle) {}
/**
* Atomically check whether `userId` is on cooldown for `command`. If not,
* set a new cooldown of `durationMs` and return `limited: false`.
*
* Retries up to 3 times on Kv contention before giving up.
*/
async checkAndSet(args: {
command: string;
userId: string;
durationMs: number;
}): Promise<CooldownCheckResult> {
if (args.durationMs <= 0) {
return { limited: false, retryAfterSeconds: 0, expiresAtMs: 0 };
}
const key = this.handle.key("cooldown", args.command, args.userId);
for (let attempt = 0; attempt < 3; attempt++) {
const existing = await this.handle.kv.get<number>(key);
const now = Date.now();
if (existing.value !== null && existing.value > now) {
return {
limited: true,
retryAfterSeconds: Math.ceil((existing.value - now) / 1000),
expiresAtMs: existing.value,
};
}
const expiresAtMs = now + args.durationMs;
const result = await this.handle.kv.atomic()
.check(existing)
.set(key, expiresAtMs, { expireIn: args.durationMs })
.commit();
if (result.ok) {
return { limited: false, retryAfterSeconds: 0, expiresAtMs };
}
// Contention — retry.
}
// Three CAS retries failed. Treat as limited so we never bypass cooldowns
// under load, and report a one-second back-off.
return { limited: true, retryAfterSeconds: 1, expiresAtMs: Date.now() + 1000 };
}
/** Peek at the current cooldown for a user/command without modifying it. */
async peek(command: string, userId: string): Promise<CooldownCheckResult> {
const key = this.handle.key("cooldown", command, userId);
const entry = await this.handle.kv.get<number>(key);
const now = Date.now();
if (entry.value === null || entry.value <= now) {
return { limited: false, retryAfterSeconds: 0, expiresAtMs: 0 };
}
return {
limited: true,
retryAfterSeconds: Math.ceil((entry.value - now) / 1000),
expiresAtMs: entry.value,
};
}
/** Manually clear a cooldown — used by admin tooling. */
async clear(command: string, userId: string): Promise<void> {
const key = this.handle.key("cooldown", command, userId);
await this.handle.kv.delete(key);
}
}

View File

@@ -0,0 +1,76 @@
/**
* Interaction state store.
*
* Discord component customIds are capped at 100 characters, which is far too
* small to embed arbitrary context. Per the architectural mandate, we keep
* interactions stateless by stashing context in Deno.Kv keyed by a short
* token (typically a ULID embedded in the customId).
*
* Records have a default TTL of 15 minutes to keep the KV store bounded —
* Discord interactions become invalid after 15 minutes anyway.
*/
import { ulid } from "@std/ulid";
import type { KvHandle } from "./store.ts";
const DEFAULT_TTL_MS = 15 * 60 * 1000;
export interface InteractionStateRecord<T> {
readonly token: string;
readonly value: T;
readonly storedAt: number;
}
export class InteractionStateStore {
constructor(private readonly handle: KvHandle) {}
/**
* Persist a context object and return a fresh ULID token. The token is
* what callers should embed in customIds — never the value itself.
*/
async create<T>(value: T, ttlMs: number = DEFAULT_TTL_MS): Promise<string> {
const token = ulid();
const key = this.handle.key("interaction", token);
const record: InteractionStateRecord<T> = {
token,
value,
storedAt: Date.now(),
};
await this.handle.kv.set(key, record, { expireIn: ttlMs });
return token;
}
/**
* Read a stored context. Returns `null` if the token doesn't exist or
* has already expired/been consumed.
*/
async read<T>(token: string): Promise<InteractionStateRecord<T> | null> {
const key = this.handle.key("interaction", token);
const entry = await this.handle.kv.get<InteractionStateRecord<T>>(key);
return entry.value;
}
/**
* Read-and-delete (consume-once semantics). Use this for one-shot
* confirm/cancel flows so a stale token can't replay the action.
*/
async consume<T>(token: string): Promise<InteractionStateRecord<T> | null> {
const key = this.handle.key("interaction", token);
const entry = await this.handle.kv.get<InteractionStateRecord<T>>(key);
if (entry.value === null) return null;
// Atomically delete only if the version we just read still matches.
const result = await this.handle.kv.atomic()
.check(entry)
.delete(key)
.commit();
return result.ok ? entry.value : null;
}
/** Manually invalidate a token (e.g. when the parent message is deleted). */
async revoke(token: string): Promise<void> {
const key = this.handle.key("interaction", token);
await this.handle.kv.delete(key);
}
}

View File

@@ -0,0 +1,73 @@
/**
* `Deno.Kv` lifecycle wrapper.
*
* Owns the single `Deno.Kv` handle for the core process and exposes typed
* helpers used by:
* - `CooldownStore` (per-command, per-user TTL gating)
* - `InteractionStateStore` (ephemeral context for stateless components)
* - `CacheStore` (generic TTL cache, e.g. for the Pika client)
*
* Why one file?
* The three concerns share the same KV handle and the same notion of
* `KvKey` prefixes. Keeping them in one module makes the prefix table
* explicit and prevents two features from accidentally colliding.
*/
import { ensureDir } from "@std/fs";
import { dirname } from "@std/path";
import type { Logger } from "@elly/shared";
export type KvNamespace = "cooldown" | "interaction" | "cache" | "meta";
export const KV_NAMESPACES: ReadonlyArray<KvNamespace> = [
"cooldown",
"interaction",
"cache",
"meta",
];
export interface KvHandle {
/** The underlying `Deno.Kv` instance. */
readonly kv: Deno.Kv;
/** Filesystem path the KV store was opened from. */
readonly path: string;
/** Build a fully-qualified KV key by namespace + segments. */
key(namespace: KvNamespace, ...segments: Deno.KvKey): Deno.KvKey;
/** Close the underlying connection. Safe to call multiple times. */
close(): Promise<void>;
}
export interface OpenKvOptions {
readonly path: string;
readonly logger: Logger;
}
/**
* Open (or create) the on-disk Deno.Kv store at `path`.
*/
export async function openKv(options: OpenKvOptions): Promise<KvHandle> {
const log = options.logger.child({ component: "kv" });
await ensureDir(dirname(options.path));
log.debug("opening deno.kv store", { path: options.path });
const kv = await Deno.openKv(options.path);
log.info("deno.kv store opened", { path: options.path });
let closed = false;
return {
kv,
path: options.path,
key(namespace: KvNamespace, ...segments: Deno.KvKey): Deno.KvKey {
return [namespace, ...segments];
},
async close(): Promise<void> {
if (closed) return;
closed = true;
log.debug("closing deno.kv store");
kv.close();
// close() on Deno.Kv is sync; await Promise.resolve to keep the async signature.
await Promise.resolve();
},
};
}

View File

@@ -0,0 +1,86 @@
/**
* In-process domain event bus.
*
* Core services publish typed domain events here; the HTTP `events` route
* (Server-Sent Events) subscribes and forwards each event to the bot.
*
* Implementation:
* - Backed by `EventTarget`/`CustomEvent` — Deno's stdlib event primitive,
* no external deps.
* - Listeners are synchronous from the bus's perspective. Subscribers
* wrap their handler in their own queue if they need backpressure
* (the SSE bridge does this via the response stream).
* - Every published event is automatically stamped with a ULID `id` and
* `timestamp` if the publisher didn't supply them.
*/
import { ulid } from "@std/ulid";
import type { AnyDomainEvent, Logger } from "@elly/shared";
const EVENT_NAME = "domain";
export type EventListener = (event: AnyDomainEvent) => void;
export type EventPublishInput =
& Omit<AnyDomainEvent, "id" | "timestamp">
& Partial<Pick<AnyDomainEvent, "id" | "timestamp">>;
export class DomainEventBus {
private readonly target = new EventTarget();
private readonly log: Logger;
private listenerCount = 0;
constructor(logger: Logger) {
this.log = logger.child({ component: "pubsub" });
}
/** Publish a fully-formed or partial event. Returns the final event object. */
publish(input: EventPublishInput): AnyDomainEvent {
const event = {
...input,
id: input.id ?? ulid(),
timestamp: input.timestamp ?? Date.now(),
} as AnyDomainEvent;
this.log.debug("publishing domain event", {
type: event.type,
id: event.id,
listeners: this.listenerCount,
});
this.target.dispatchEvent(new CustomEvent<AnyDomainEvent>(EVENT_NAME, { detail: event }));
return event;
}
/**
* Subscribe to every event. Returns an unsubscribe function — call it
* exactly once when the consumer is torn down (e.g. SSE client disconnect).
*/
subscribe(handler: EventListener): () => void {
const wrapped = (e: Event) => {
const ev = (e as CustomEvent<AnyDomainEvent>).detail;
try {
handler(ev);
} catch (err) {
this.log.error("subscriber threw", {
eventType: ev.type,
eventId: ev.id,
err: err instanceof Error ? err : new Error(String(err)),
});
}
};
this.target.addEventListener(EVENT_NAME, wrapped);
this.listenerCount++;
return () => {
this.target.removeEventListener(EVENT_NAME, wrapped);
this.listenerCount = Math.max(0, this.listenerCount - 1);
};
}
/** Active listener count — useful for /v1/version diagnostics. */
get subscribers(): number {
return this.listenerCount;
}
}

165
crates/core/src/main.ts Normal file
View File

@@ -0,0 +1,165 @@
/**
* @elly/core entrypoint — Phase 2 boot.
*
* Lifecycle:
* 1. Load + validate `config.toml` (Zod).
* 2. Load + validate environment (Zod).
* 3. Build the root structured logger.
* 4. Build the DI container (DB, migrations, KV, bus, HTTP server).
* 5. Start the IPC HTTP server.
* 6. Publish `server.ready` on the domain bus.
* 7. Block on SIGINT/SIGTERM, then gracefully shut down the container.
*/
import {
ConfigError,
ConfigValidationError,
CoreEnvSchema,
createLogger,
EnvValidationError,
loadConfig,
loadEnv,
type Config,
type CoreEnv,
type Logger,
} from "@elly/shared";
import { buildContainer, type CoreContainer } from "./container.ts";
const CONFIG_PATH = Deno.env.get("CONFIG_PATH") ?? "./config.toml";
const LOGGER_NAME = "@elly/core";
const VERSION = "0.1.0";
async function main(): Promise<void> {
const config = await loadConfigOrExit();
const env = loadEnvOrExit();
const logger = buildLogger(config, env);
logger.info("phase 2 boot starting", {
crate: "core",
nodeEnv: env.NODE_ENV,
version: VERSION,
});
let container: CoreContainer | null = null;
try {
container = await buildContainer({ config, env, logger, version: VERSION });
} catch (err) {
logger.fatal("container build failed", {
err: err instanceof Error ? err : new Error(String(err)),
});
await logger.flush();
Deno.exit(1);
}
installSignalHandlers(container, logger);
try {
await container.http.start();
} catch (err) {
logger.fatal("ipc server failed to start", {
err: err instanceof Error ? err : new Error(String(err)),
});
await container.shutdown();
await logger.flush();
Deno.exit(1);
}
container.bus.publish({
type: "server.ready",
payload: { version: VERSION, pid: Deno.pid },
});
logger.info("phase 2 boot complete", {
pid: Deno.pid,
ipc: `${config.ipc.host}:${config.ipc.port}`,
});
// Hold the process open until a signal handler tears the container down.
await new Promise<void>(() => {});
}
// =====================================================================
// Boot helpers
// =====================================================================
async function loadConfigOrExit(): Promise<Config> {
try {
return await loadConfig(CONFIG_PATH);
} catch (err) {
bootFail(err, "config");
}
}
function loadEnvOrExit(): CoreEnv {
try {
return loadEnv(CoreEnvSchema);
} catch (err) {
bootFail(err, "env");
}
}
function buildLogger(config: Config, env: CoreEnv): Logger {
const level = env.LOG_LEVEL ?? config.logging.level;
const isProd = env.NODE_ENV === "production";
return createLogger({
name: LOGGER_NAME,
level,
format: isProd ? "json" : config.logging.format,
file: config.logging.file
? {
path: config.logging.file,
maxBytes: config.logging.file_max_bytes,
maxBackups: config.logging.file_max_backups,
}
: undefined,
});
}
function installSignalHandlers(container: CoreContainer, logger: Logger): void {
let shuttingDown = false;
const shutdown = (signal: string) => {
if (shuttingDown) {
logger.warn("shutdown already in progress; ignoring signal", { signal });
return;
}
shuttingDown = true;
logger.info("shutdown signal received", { signal });
container
.shutdown()
.catch((err) => {
logger.error("shutdown error", {
err: err instanceof Error ? err : new Error(String(err)),
});
})
.finally(() => {
logger.flush().finally(() => Deno.exit(0));
});
};
Deno.addSignalListener("SIGINT", () => shutdown("SIGINT"));
Deno.addSignalListener("SIGTERM", () => shutdown("SIGTERM"));
}
function bootFail(err: unknown, stage: "config" | "env"): never {
if (err instanceof ConfigValidationError || err instanceof EnvValidationError) {
console.error(`[@elly/core] ${stage} validation failed:`);
for (const issue of err.issues) {
console.error(` - ${issue.path}: ${issue.message}`);
}
Deno.exit(1);
}
if (err instanceof ConfigError) {
console.error(`[@elly/core] ${stage} error: ${err.message}`);
Deno.exit(1);
}
console.error(`[@elly/core] unexpected ${stage} error:`, err);
Deno.exit(1);
}
if (import.meta.main) {
main().catch((err) => {
console.error("[@elly/core] fatal boot error:", err);
Deno.exit(1);
});
}

5
crates/shared/deno.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "@elly/shared",
"version": "0.1.0",
"exports": "./mod.ts"
}

106
crates/shared/mod.ts Normal file
View File

@@ -0,0 +1,106 @@
/**
* @elly/shared — Public API barrel.
*
* Both `@elly/core` and `@elly/bot` import from here. This barrel is the only
* sanctioned cross-crate surface. Anything not re-exported is considered
* implementation detail and may change without notice.
*/
// ----- Config --------------------------------------------------------
export {
ApiSchema,
BotSchema,
ChannelsSchema,
ColorsSchema,
ConfigSchema,
DatabaseSchema,
FeaturesSchema,
GuildSchema,
IpcSchema,
KvSchema,
LimitsSchema,
LoggingSchema,
RolesSchema,
} from "./src/config/schema.ts";
export type { Config } from "./src/config/schema.ts";
export type {
ApiConfig,
BotConfig,
ChannelsConfig,
ColorsConfig,
ConfigInput,
DatabaseConfig,
FeaturesConfig,
GuildConfig,
IpcConfig,
KvConfig,
LimitsConfig,
LoggingConfig,
RolesConfig,
} from "./src/config/types.ts";
export { loadConfig, validateConfig } from "./src/config/loader.ts";
export {
BotEnvSchema,
CoreEnvSchema,
loadEnv,
SharedEnvSchema,
} from "./src/config/env.ts";
export type { BotEnv, CoreEnv, SharedEnv } from "./src/config/env.ts";
export {
ConfigError,
ConfigValidationError,
EnvValidationError,
} from "./src/config/errors.ts";
export type { ConfigValidationIssue } from "./src/config/errors.ts";
// ----- Logger --------------------------------------------------------
export { createConsoleLogger, createLogger } from "./src/logger/factory.ts";
export { LOG_LEVEL_ORDER } from "./src/logger/types.ts";
export type {
LogFileOptions,
Logger,
LoggerOptions,
LogLevel,
LogRecord,
} from "./src/logger/types.ts";
// ----- IPC contract --------------------------------------------------
export {
IPC_AUTH_HEADER,
IPC_REQUEST_ID_HEADER,
IpcRoutes,
} from "./src/ipc/routes.ts";
export type { IpcRoute } from "./src/ipc/routes.ts";
export {
IPC_ERROR_STATUS,
IpcError,
IpcErrorBodySchema,
IpcErrorCode,
} from "./src/ipc/errors.ts";
export type { IpcErrorBody } from "./src/ipc/errors.ts";
export {
AnyDomainEventSchema,
HeartbeatEventSchema,
ServerReadyEventSchema,
} from "./src/ipc/events.ts";
export type {
AnyDomainEvent,
BaseDomainEvent,
DomainEventType,
HeartbeatEvent,
ServerReadyEvent,
} from "./src/ipc/events.ts";
// ----- Util ----------------------------------------------------------
export {
err,
isErr,
isOk,
map,
mapErr,
ok,
tryCatch,
tryCatchAsync,
unwrap,
} from "./src/util/result.ts";
export type { Err, Ok, Result } from "./src/util/result.ts";

View File

@@ -0,0 +1,69 @@
/**
* Environment variable schemas and loader.
*
* Each crate validates only the env it actually needs:
* - `@elly/core` → `CoreEnvSchema`
* - `@elly/bot` → `BotEnvSchema`
*
* Loading is fail-fast: a missing or malformed required variable aborts
* the process before any side-effectful work begins.
*/
import { z } from "zod";
import { EnvValidationError, type ConfigValidationIssue } from "./errors.ts";
export const SharedEnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error", "fatal"]).optional(),
});
export const CoreEnvSchema = SharedEnvSchema.extend({
IPC_TOKEN: z
.string()
.min(16, "IPC_TOKEN must be at least 16 characters (use a long random string)"),
});
export const BotEnvSchema = SharedEnvSchema.extend({
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
IPC_TOKEN: z
.string()
.min(16, "IPC_TOKEN must be at least 16 characters (must match the core crate)"),
});
export type SharedEnv = z.infer<typeof SharedEnvSchema>;
export type CoreEnv = z.infer<typeof CoreEnvSchema>;
export type BotEnv = z.infer<typeof BotEnvSchema>;
/**
* Load and validate environment variables against a Zod object schema.
*
* Only keys declared on the schema's `shape` are read; everything else in
* `Deno.env` is ignored to prevent accidental coupling.
*
* @throws {EnvValidationError} If any required variable is missing or invalid.
*/
export function loadEnv<S extends z.ZodObject<z.ZodRawShape>>(schema: S): z.output<S> {
const raw: Record<string, string> = {};
for (const key of Object.keys(schema.shape)) {
const value = Deno.env.get(key);
if (value !== undefined) {
raw[key] = value;
}
}
const result = schema.safeParse(raw);
if (result.success) {
return result.data;
}
const issues: ConfigValidationIssue[] = result.error.issues.map((issue) => ({
path: issue.path.length === 0 ? "<env>" : String(issue.path[0]),
message: issue.message,
code: issue.code,
}));
const summary = `Environment validation failed:\n${
issues.map((i) => ` - ${i.path}: ${i.message}`).join("\n")
}`;
throw new EnvValidationError(summary, issues);
}

View File

@@ -0,0 +1,43 @@
/**
* Error types raised by the config and env loaders.
*
* These are deliberately distinct from `Error` subclasses inside `@elly/core`
* or `@elly/bot` — they cross the workspace boundary and must be importable
* by both runtimes without dragging unrelated dependencies along.
*/
export class ConfigError extends Error {
readonly path: string;
constructor(message: string, path: string) {
super(message);
this.name = "ConfigError";
this.path = path;
}
}
export class ConfigValidationError extends ConfigError {
readonly issues: readonly ConfigValidationIssue[];
constructor(message: string, path: string, issues: readonly ConfigValidationIssue[]) {
super(message, path);
this.name = "ConfigValidationError";
this.issues = issues;
}
}
export interface ConfigValidationIssue {
readonly path: string;
readonly message: string;
readonly code: string;
}
export class EnvValidationError extends Error {
readonly issues: readonly ConfigValidationIssue[];
constructor(message: string, issues: readonly ConfigValidationIssue[]) {
super(message);
this.name = "EnvValidationError";
this.issues = issues;
}
}

View File

@@ -0,0 +1,79 @@
/**
* TOML config loader for the Elly Discord Bot.
*
* Loads the config file from disk, parses it as TOML, validates it against
* `ConfigSchema`, and returns a fully-typed `Config`. Validation failures
* throw `ConfigValidationError` with structured per-path issues so the
* boot scripts can render a helpful error and exit with a non-zero code.
*/
import { parse as parseToml } from "@std/toml";
import type { z } from "zod";
import { ConfigSchema, type Config } from "./schema.ts";
import {
ConfigError,
ConfigValidationError,
type ConfigValidationIssue,
} from "./errors.ts";
/**
* Read, parse, and validate the config file at `path`.
*
* @throws {ConfigError} If the file is missing or cannot be parsed as TOML.
* @throws {ConfigValidationError} If the parsed object doesn't satisfy the schema.
*/
export async function loadConfig(path: string): Promise<Config> {
const text = await readFile(path);
const raw = parseTomlText(text, path);
return validateConfig(raw, path);
}
/**
* Validate an already-parsed object against `ConfigSchema`. Exposed so tests
* and tooling can validate config objects produced by other sources without
* going through the filesystem.
*/
export function validateConfig(input: unknown, path = "<in-memory>"): Config {
const result = ConfigSchema.safeParse(input);
if (result.success) return result.data;
const issues = zodIssuesToConfigIssues(result.error);
const summary = `Config validation failed for ${path}:\n${
issues.map((i) => ` - ${i.path}: ${i.message}`).join("\n")
}`;
throw new ConfigValidationError(summary, path, issues);
}
async function readFile(path: string): Promise<string> {
try {
return await Deno.readTextFile(path);
} catch (err) {
if (err instanceof Deno.errors.NotFound) {
throw new ConfigError(`Config file not found at ${path}`, path);
}
if (err instanceof Deno.errors.PermissionDenied) {
throw new ConfigError(
`Permission denied reading config at ${path}. Did you grant --allow-read for this path?`,
path,
);
}
throw err;
}
}
function parseTomlText(text: string, path: string): Record<string, unknown> {
try {
return parseToml(text);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw new ConfigError(`Failed to parse TOML at ${path}: ${reason}`, path);
}
}
function zodIssuesToConfigIssues(error: z.ZodError): ConfigValidationIssue[] {
return error.issues.map((issue) => ({
path: issue.path.length === 0 ? "<root>" : issue.path.join("."),
message: issue.message,
code: issue.code,
}));
}

View File

@@ -0,0 +1,154 @@
/**
* Zod schema for the Elly Discord Bot's TOML configuration.
*
* This is the canonical contract between the operator (`config.toml`) and
* both `@elly/core` and `@elly/bot`. Parsing happens once at boot — if it
* fails, the process exits with a structured error.
*
* Type inference (`Config = z.infer<typeof ConfigSchema>`) lives in
* `./types.ts` and is re-exported from the crate's barrel.
*/
import { z } from "zod";
const DiscordId = z
.string()
.regex(/^\d{17,19}$/, "Must be a 1719 digit Discord snowflake ID");
const NonEmpty = z.string().min(1, "Must not be empty");
const Color = z
.number()
.int()
.min(0, "Color must be >= 0")
.max(0xFFFFFF, "Color must be <= 0xFFFFFF");
export const BotSchema = z.object({
name: NonEmpty,
prefix: NonEmpty,
status: z.string().default(""),
activity_type: z
.enum(["playing", "streaming", "listening", "watching", "competing"])
.default("watching"),
owners: z
.object({
ids: z.array(DiscordId).default([]),
})
.default({ ids: [] }),
});
export const DatabaseSchema = z.object({
path: NonEmpty,
});
export const KvSchema = z
.object({
path: z.string().default("./data/kv"),
})
.default({ path: "./data/kv" });
export const IpcSchema = z
.object({
host: z.string().default("127.0.0.1"),
port: z.number().int().min(1).max(65535).default(8787),
request_timeout_ms: z.number().int().positive().default(10_000),
})
.default({ host: "127.0.0.1", port: 8787, request_timeout_ms: 10_000 });
export const ApiSchema = z.object({
pika_cache_ttl: z.number().int().positive().default(3_600_000),
pika_request_timeout: z.number().int().positive().default(10_000),
});
export const GuildSchema = z.object({
id: DiscordId,
name: NonEmpty,
});
export const ChannelsSchema = z.object({
applications: NonEmpty,
application_logs: NonEmpty,
suggestions: NonEmpty,
suggestion_logs: NonEmpty,
guild_updates: NonEmpty,
discord_changelog: NonEmpty,
inactivity: NonEmpty,
development_logs: NonEmpty,
donations: NonEmpty,
reminders: NonEmpty,
});
export const RolesSchema = z.object({
admin: NonEmpty,
leader: NonEmpty,
officer: NonEmpty,
developer: NonEmpty,
guild_member: NonEmpty,
champion: NonEmpty,
away: NonEmpty,
applications_blacklisted: NonEmpty,
suggestions_blacklisted: NonEmpty,
manageable: z
.object({
ids: z.array(DiscordId).default([]),
})
.default({ ids: [] }),
});
export const FeaturesSchema = z.object({
applications: z.boolean().default(true),
suggestions: z.boolean().default(true),
statistics: z.boolean().default(true),
family: z.boolean().default(true),
qotd: z.boolean().default(true),
reminders: z.boolean().default(true),
staff_simulator: z.boolean().default(true),
channel_filtering: z.boolean().default(true),
auto_moderation: z.boolean().default(false),
welcome_system: z.boolean().default(false),
level_system: z.boolean().default(false),
});
export const LimitsSchema = z.object({
champion_max_days: z.number().int().positive().default(366),
away_max_days: z.number().int().positive().default(355),
purge_max_messages: z.number().int().min(1).max(100).default(100),
reminder_max_duration_days: z.number().int().positive().default(365),
});
export const ColorsSchema = z.object({
primary: Color.default(0x5865F2),
success: Color.default(0x57F287),
warning: Color.default(0xFEE75C),
error: Color.default(0xED4245),
info: Color.default(0x5865F2),
});
export const LoggingSchema = z.object({
level: z.enum(["debug", "info", "warn", "error", "fatal"]).default("info"),
format: z.enum(["console", "json"]).default("console"),
file: z.string().optional(),
file_max_bytes: z
.number()
.int()
.positive()
.default(10 * 1024 * 1024),
file_max_backups: z.number().int().min(0).default(5),
});
export const ConfigSchema = z.object({
bot: BotSchema,
database: DatabaseSchema,
kv: KvSchema,
ipc: IpcSchema,
api: ApiSchema,
guild: GuildSchema,
channels: ChannelsSchema,
roles: RolesSchema,
features: FeaturesSchema,
limits: LimitsSchema,
colors: ColorsSchema,
logging: LoggingSchema,
});
export type Config = z.infer<typeof ConfigSchema>;

View File

@@ -0,0 +1,38 @@
/**
* Type re-exports for consumers that only need the inferred types
* (no runtime cost — schemas are tree-shaken out for type-only imports).
*/
import type {
ApiSchema,
BotSchema,
ChannelsSchema,
ColorsSchema,
Config,
ConfigSchema,
DatabaseSchema,
FeaturesSchema,
GuildSchema,
IpcSchema,
KvSchema,
LimitsSchema,
LoggingSchema,
RolesSchema,
} from "./schema.ts";
import type { z } from "zod";
export type { Config };
export type ConfigInput = z.input<typeof ConfigSchema>;
export type BotConfig = z.infer<typeof BotSchema>;
export type DatabaseConfig = z.infer<typeof DatabaseSchema>;
export type KvConfig = z.infer<typeof KvSchema>;
export type IpcConfig = z.infer<typeof IpcSchema>;
export type ApiConfig = z.infer<typeof ApiSchema>;
export type GuildConfig = z.infer<typeof GuildSchema>;
export type ChannelsConfig = z.infer<typeof ChannelsSchema>;
export type RolesConfig = z.infer<typeof RolesSchema>;
export type FeaturesConfig = z.infer<typeof FeaturesSchema>;
export type LimitsConfig = z.infer<typeof LimitsSchema>;
export type ColorsConfig = z.infer<typeof ColorsSchema>;
export type LoggingConfig = z.infer<typeof LoggingSchema>;

View File

@@ -0,0 +1,81 @@
/**
* IPC error envelope — the canonical JSON shape returned for any non-2xx
* response from `@elly/core`. The bot's `CoreClient` parses this and
* surfaces it as a typed error to callers.
*/
import { z } from "zod";
/**
* Stable machine-readable error codes. Adding a code is a non-breaking
* change; renaming/removing one is a breaking change.
*/
export const IpcErrorCode = {
UNAUTHORIZED: "unauthorized",
FORBIDDEN: "forbidden",
NOT_FOUND: "not_found",
METHOD_NOT_ALLOWED: "method_not_allowed",
BAD_REQUEST: "bad_request",
VALIDATION_FAILED: "validation_failed",
CONFLICT: "conflict",
RATE_LIMITED: "rate_limited",
UPSTREAM_FAILURE: "upstream_failure",
INTERNAL: "internal",
} as const;
export type IpcErrorCode = typeof IpcErrorCode[keyof typeof IpcErrorCode];
export const IpcErrorBodySchema = z.object({
error: z.object({
code: z.string(),
message: z.string(),
requestId: z.string().optional(),
details: z.unknown().optional(),
}),
});
export type IpcErrorBody = z.infer<typeof IpcErrorBodySchema>;
/**
* Map of `IpcErrorCode` -> default HTTP status code. The server overrides
* the status per response when needed; this map exists so callers (and the
* default error middleware) have a sensible fallback.
*/
export const IPC_ERROR_STATUS: Record<IpcErrorCode, number> = {
unauthorized: 401,
forbidden: 403,
not_found: 404,
method_not_allowed: 405,
bad_request: 400,
validation_failed: 422,
conflict: 409,
rate_limited: 429,
upstream_failure: 502,
internal: 500,
};
/**
* Error thrown by `CoreClient` (Phase 3) when an IPC response carries an
* `IpcErrorBody`. Exported here so both crates can `instanceof`-check.
*/
export class IpcError extends Error {
readonly code: IpcErrorCode | string;
readonly status: number;
readonly requestId?: string;
readonly details?: unknown;
constructor(args: {
code: IpcErrorCode | string;
message: string;
status: number;
requestId?: string;
details?: unknown;
}) {
super(args.message);
this.name = "IpcError";
this.code = args.code;
this.status = args.status;
this.requestId = args.requestId;
this.details = args.details;
}
}

View File

@@ -0,0 +1,71 @@
/**
* Domain event contract.
*
* Core publishes typed events to an in-process bus and bridges them to the
* bot over the SSE endpoint (`IpcRoutes.EVENTS`). Each event has:
* - `type`: a string literal discriminator (`"applications.approved"`, …)
* - `id`: a ULID, unique per event, useful for idempotency on the consumer
* - `timestamp`: epoch milliseconds
* - `payload`: type-narrowed by `type`
*
* Phase 4 extends `AnyDomainEvent` with feature-specific events. Phase 2
* ships only `heartbeat` and `server.ready` for SSE plumbing verification.
*/
import { z } from "zod";
export interface BaseDomainEvent<TType extends string, TPayload> {
readonly type: TType;
readonly id: string;
readonly timestamp: number;
readonly payload: TPayload;
}
// ---------------------------------------------------------------------
// Phase 2 events
// ---------------------------------------------------------------------
export interface ServerReadyEvent
extends BaseDomainEvent<"server.ready", { version: string; pid: number }> {}
export interface HeartbeatEvent
extends BaseDomainEvent<"heartbeat", { uptimeMs: number }> {}
// ---------------------------------------------------------------------
// Discriminated union of every event the bot can receive
// ---------------------------------------------------------------------
export type AnyDomainEvent = ServerReadyEvent | HeartbeatEvent;
export type DomainEventType = AnyDomainEvent["type"];
// ---------------------------------------------------------------------
// Runtime validation (used by the bot when parsing SSE messages)
// ---------------------------------------------------------------------
const BaseEventShape = {
id: z.string().min(1),
timestamp: z.number().int().nonnegative(),
};
export const ServerReadyEventSchema = z.object({
type: z.literal("server.ready"),
...BaseEventShape,
payload: z.object({
version: z.string(),
pid: z.number().int(),
}),
});
export const HeartbeatEventSchema = z.object({
type: z.literal("heartbeat"),
...BaseEventShape,
payload: z.object({
uptimeMs: z.number().int().nonnegative(),
}),
});
export const AnyDomainEventSchema = z.discriminatedUnion("type", [
ServerReadyEventSchema,
HeartbeatEventSchema,
]);

View File

@@ -0,0 +1,30 @@
/**
* IPC route constants.
*
* Single source of truth for all HTTP paths exposed by `@elly/core`. Both
* the server (route registration) and the bot's `CoreClient` (URL construction)
* import from here so the two cannot drift.
*
* Versioned under `/v1/...` — future incompatible changes ship as `/v2/...`
* while keeping `/v1/` alive during transitions.
*/
export const IpcRoutes = {
/** Unauthenticated liveness probe. Returns 200 if the process is alive. */
HEALTH: "/health",
/** Authenticated version/diagnostic payload. */
VERSION: "/v1/version",
/** Server-sent events stream of domain events for the bot to react to. */
EVENTS: "/v1/events",
} as const;
export type IpcRoute = typeof IpcRoutes[keyof typeof IpcRoutes];
/** Standard request header carrying the IPC bearer token. */
export const IPC_AUTH_HEADER = "authorization";
/** Header for correlating a request across crates and log lines. */
export const IPC_REQUEST_ID_HEADER = "x-request-id";

View File

@@ -0,0 +1,141 @@
/**
* Universal structured logger factory.
*
* Construct one logger per crate at boot and pass it through the dependency
* container. Child loggers share their parent's sinks — call `.child()`
* freely for per-request, per-command, or per-job context.
*/
import {
LOG_LEVEL_ORDER,
type LogLevel,
type Logger,
type LoggerOptions,
type LogRecord,
type LogSink,
} from "./types.ts";
import { ConsoleSink, RotatingFileSink } from "./sinks.ts";
/**
* Build a configured root logger.
*
* Sinks created:
* - one `ConsoleSink` (always)
* - one `RotatingFileSink` (only if `options.file` is provided)
*/
export function createLogger(options: LoggerOptions): Logger {
const sinks: LogSink[] = [new ConsoleSink(options.format)];
if (options.file) {
sinks.push(
new RotatingFileSink(options.file.path, {
maxBytes: options.file.maxBytes,
maxBackups: options.file.maxBackups,
}),
);
}
return new StructuredLogger(options.name, options.level, sinks, {});
}
/**
* Convenience helper for crates that want a logger without any persistent
* file sink (e.g. tests, ad-hoc scripts). Output is colorized console only.
*/
export function createConsoleLogger(name: string, level: LogLevel = "info"): Logger {
return createLogger({ name, level, format: "console" });
}
// =====================================================================
// Implementation
// =====================================================================
class StructuredLogger implements Logger {
constructor(
private readonly name: string,
private readonly level: LogLevel,
private readonly sinks: ReadonlyArray<LogSink>,
private readonly context: Readonly<Record<string, unknown>>,
) {}
debug(msg: string, fields?: Record<string, unknown>): void {
this.log("debug", msg, fields);
}
info(msg: string, fields?: Record<string, unknown>): void {
this.log("info", msg, fields);
}
warn(msg: string, fields?: Record<string, unknown>): void {
this.log("warn", msg, fields);
}
error(msg: string, fields?: Record<string, unknown>): void {
this.log("error", msg, fields);
}
fatal(msg: string, fields?: Record<string, unknown>): void {
this.log("fatal", msg, fields);
}
child(context: Record<string, unknown>): Logger {
return new StructuredLogger(this.name, this.level, this.sinks, {
...this.context,
...context,
});
}
async flush(): Promise<void> {
await Promise.all(this.sinks.map((s) => s.flush()));
}
private log(level: LogLevel, msg: string, fields?: Record<string, unknown>): void {
if (LOG_LEVEL_ORDER[level] < LOG_LEVEL_ORDER[this.level]) return;
const fieldsNormalized = normalizeFields(fields);
const record: LogRecord = {
time: new Date().toISOString(),
level,
logger: this.name,
msg,
...this.context,
...fieldsNormalized,
};
for (const sink of this.sinks) {
sink.write(record);
}
}
}
/**
* Normalize special values in user-supplied fields:
* - Errors are expanded into `{ name, message, stack, cause? }` for JSON
* serialization. The console formatter receives the same expanded shape.
*/
function normalizeFields(
fields: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!fields) return undefined;
let mutated: Record<string, unknown> | null = null;
for (const [k, v] of Object.entries(fields)) {
if (v instanceof Error) {
mutated ??= { ...fields };
mutated[k] = serializeError(v);
}
}
return mutated ?? fields;
}
function serializeError(err: Error): Record<string, unknown> {
const out: Record<string, unknown> = {
name: err.name,
message: err.message,
};
if (err.stack) out.stack = err.stack;
const cause = (err as { cause?: unknown }).cause;
if (cause !== undefined) {
out.cause = cause instanceof Error ? serializeError(cause) : cause;
}
return out;
}

View File

@@ -0,0 +1,202 @@
/**
* Logger sink implementations.
*
* - `ConsoleSink`: synchronous writes to stdout/stderr, optionally
* ANSI-colorized for `console` format or compact JSON for `json` format.
* - `RotatingFileSink`: asynchronous, append-only JSON lines with size-based
* rotation. Writes are serialized through a promise queue so concurrent
* `write()` calls cannot interleave.
*
* Both sinks survive transient errors (logged to stderr) — a failing log
* sink must never crash the host process.
*/
import { dirname } from "@std/path";
import { ensureDir } from "@std/fs";
import {
bold,
cyan,
gray,
green,
magenta,
red,
yellow,
} from "@std/fmt/colors";
import {
LOG_LEVEL_ORDER,
type LogLevel,
type LogRecord,
type LogSink,
} from "./types.ts";
const encoder = new TextEncoder();
const LEVEL_COLOR: Record<LogLevel, (s: string) => string> = {
debug: cyan,
info: green,
warn: yellow,
error: red,
fatal: (s) => bold(magenta(s)),
};
// =====================================================================
// ConsoleSink
// =====================================================================
export class ConsoleSink implements LogSink {
constructor(private readonly format: "console" | "json") {}
write(record: LogRecord): void {
const line = this.format === "json"
? JSON.stringify(record) + "\n"
: this.formatConsole(record) + "\n";
const useStderr = LOG_LEVEL_ORDER[record.level] >= LOG_LEVEL_ORDER.warn;
const writer = useStderr ? Deno.stderr : Deno.stdout;
try {
writer.writeSync(encoder.encode(line));
} catch {
// Never propagate sink failures to user code.
}
}
flush(): Promise<void> {
return Promise.resolve();
}
private formatConsole(record: LogRecord): string {
const { time, level, logger, msg, ...rest } = record;
const ts = gray(time);
const lvl = LEVEL_COLOR[level](level.toUpperCase().padEnd(5));
const name = cyan(`[${logger}]`);
let fields = "";
const keys = Object.keys(rest);
if (keys.length > 0) {
const parts: string[] = [];
for (const k of keys) {
parts.push(`${k}=${formatValue(rest[k])}`);
}
fields = " " + gray(parts.join(" "));
}
return `${ts} ${lvl} ${name} ${msg}${fields}`;
}
}
function formatValue(v: unknown): string {
if (v === null) return "null";
if (v === undefined) return "undefined";
switch (typeof v) {
case "string":
return /\s/.test(v) ? JSON.stringify(v) : v;
case "number":
case "boolean":
case "bigint":
return String(v);
case "function":
return "[function]";
}
if (v instanceof Error) {
return `${v.name}: ${v.message}`;
}
try {
return JSON.stringify(v);
} catch {
return String(v);
}
}
// =====================================================================
// RotatingFileSink
// =====================================================================
export class RotatingFileSink implements LogSink {
private queue: Promise<void> = Promise.resolve();
private readonly maxBytes: number;
private readonly maxBackups: number;
private dirEnsured = false;
constructor(
private readonly path: string,
options: { maxBytes?: number; maxBackups?: number } = {},
) {
this.maxBytes = options.maxBytes ?? 10 * 1024 * 1024;
this.maxBackups = options.maxBackups ?? 5;
}
write(record: LogRecord): void {
const line = JSON.stringify(record) + "\n";
this.queue = this.queue.then(() => this.doWrite(line)).catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
try {
Deno.stderr.writeSync(
encoder.encode(`[logger] RotatingFileSink write failed: ${msg}\n`),
);
} catch {
// give up
}
});
}
async flush(): Promise<void> {
await this.queue;
}
private async doWrite(line: string): Promise<void> {
if (!this.dirEnsured) {
await ensureDir(dirname(this.path));
this.dirEnsured = true;
}
try {
const stat = await Deno.stat(this.path);
if (stat.size + line.length > this.maxBytes) {
await this.rotate();
}
} catch (err) {
if (!(err instanceof Deno.errors.NotFound)) throw err;
}
await Deno.writeTextFile(this.path, line, { append: true });
}
private async rotate(): Promise<void> {
// Remove the oldest backup if it would be pushed past the retention limit.
if (this.maxBackups > 0) {
const oldest = `${this.path}.${this.maxBackups}`;
try {
await Deno.remove(oldest);
} catch (err) {
if (!(err instanceof Deno.errors.NotFound)) throw err;
}
}
// Shift .{i} -> .{i+1} for i in [maxBackups-1 .. 1].
for (let i = this.maxBackups - 1; i >= 1; i--) {
const src = `${this.path}.${i}`;
const dst = `${this.path}.${i + 1}`;
try {
await Deno.rename(src, dst);
} catch (err) {
if (!(err instanceof Deno.errors.NotFound)) throw err;
}
}
// Rotate the active log to .1 (only if there is at least one backup slot).
if (this.maxBackups > 0) {
try {
await Deno.rename(this.path, `${this.path}.1`);
} catch (err) {
if (!(err instanceof Deno.errors.NotFound)) throw err;
}
} else {
// No retention — just truncate.
try {
await Deno.remove(this.path);
} catch (err) {
if (!(err instanceof Deno.errors.NotFound)) throw err;
}
}
}
}

View File

@@ -0,0 +1,76 @@
/**
* Public type contract for the universal structured logger.
*
* Both `@elly/core` and `@elly/bot` consume this. Sinks (console, file)
* are implementation details and never appear in user code.
*/
export type LogLevel = "debug" | "info" | "warn" | "error" | "fatal";
export const LOG_LEVEL_ORDER: Record<LogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
fatal: 50,
};
export interface LogRecord {
/** ISO 8601 timestamp (always UTC). */
readonly time: string;
/** Severity level. */
readonly level: LogLevel;
/** Logger name (typically a crate identifier, e.g. `@elly/core`). */
readonly logger: string;
/** Human-readable message. */
readonly msg: string;
/** Arbitrary structured context — flattened into the record. */
readonly [key: string]: unknown;
}
export interface LogFileOptions {
readonly path: string;
readonly maxBytes?: number;
readonly maxBackups?: number;
}
export interface LoggerOptions {
/** Logger name attached to every record. */
readonly name: string;
/** Minimum level to emit (records below this are silently dropped). */
readonly level: LogLevel;
/** Console output format. File output is always JSON. */
readonly format: "console" | "json";
/** Optional rotating-JSON file sink configuration. */
readonly file?: LogFileOptions;
}
export interface Logger {
debug(msg: string, fields?: Record<string, unknown>): void;
info(msg: string, fields?: Record<string, unknown>): void;
warn(msg: string, fields?: Record<string, unknown>): void;
error(msg: string, fields?: Record<string, unknown>): void;
fatal(msg: string, fields?: Record<string, unknown>): void;
/**
* Derive a child logger with extra static context merged into every record.
* The parent's sinks are shared — child loggers do not allocate new file
* handles or buffers.
*/
child(context: Record<string, unknown>): Logger;
/**
* Wait for any buffered/asynchronous sink writes (e.g. the rotating file
* sink) to flush. Call before process exit to avoid losing log lines.
*/
flush(): Promise<void>;
}
/**
* Sinks are the pluggable output backends used by the logger. Internal —
* not exported from the crate barrel.
*/
export interface LogSink {
write(record: LogRecord): void;
flush(): Promise<void>;
}

View File

@@ -0,0 +1,75 @@
/**
* Result<T, E> — a lightweight, framework-agnostic result type used across
* `@elly/shared`, `@elly/core`, and `@elly/bot`.
*
* Prefer this over throwing for expected/recoverable failures crossing
* module or IPC boundaries. Reserve exceptions for genuinely exceptional
* conditions (programmer error, invariant violation, infrastructure failure).
*/
export type Ok<T> = { readonly ok: true; readonly value: T };
export type Err<E> = { readonly ok: false; readonly error: E };
export type Result<T, E = Error> = Ok<T> | Err<E>;
export function ok<T>(value: T): Ok<T> {
return { ok: true, value };
}
export function err<E>(error: E): Err<E> {
return { ok: false, error };
}
export function isOk<T, E>(result: Result<T, E>): result is Ok<T> {
return result.ok;
}
export function isErr<T, E>(result: Result<T, E>): result is Err<E> {
return !result.ok;
}
/**
* Unwrap a Result or throw the error. Use sparingly — defeats the purpose of
* Result in most call sites.
*/
export function unwrap<T, E>(result: Result<T, E>): T {
if (result.ok) return result.value;
throw result.error instanceof Error
? result.error
: new Error(`Result.unwrap on Err: ${String(result.error)}`);
}
/**
* Map the success value of a Result without affecting the error branch.
*/
export function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
return result.ok ? ok(fn(result.value)) : result;
}
/**
* Map the error value of a Result without affecting the success branch.
*/
export function mapErr<T, E, F>(result: Result<T, E>, fn: (error: E) => F): Result<T, F> {
return result.ok ? result : err(fn(result.error));
}
/**
* Wrap a throwing synchronous function in a Result.
*/
export function tryCatch<T>(fn: () => T): Result<T, Error> {
try {
return ok(fn());
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)));
}
}
/**
* Wrap a throwing async function in a Result.
*/
export async function tryCatchAsync<T>(fn: () => Promise<T>): Promise<Result<T, Error>> {
try {
return ok(await fn());
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)));
}
}