Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions src/extensions/configResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* Extension configuration resolver.
*
* Encapsulates the layered config lookup (env var > SQLite > schema default > caller default)
* and the settings cache lifecycle. Extracted from extensionContext.ts so the resolution
* logic is independently testable and reusable.
*
* @module
*/

import { schema } from "@src/db";
import { eq } from "drizzle-orm";
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
import type { ConfigValue } from "./types";

/**
* Dependencies for creating a config resolver instance.
*/
export interface ConfigResolverDeps {
/** The extension name (used to derive env var prefix). */
extensionName: string;
/** The shared Drizzle database instance. */
database: BunSQLiteDatabase<Record<string, unknown>>;
/** The extension's settingsSchema (TypeBox TObject), if declared. */
settingsSchema?: Record<string, unknown>;
}

/**
* Creates a scoped config resolver for a single extension.
*
* Provides a `get(key, default?)` method with layered precedence:
* 1. Environment variable `EXT_{NAME}_{KEY}`
* 2. SQLite persisted settings (cached in memory)
* 3. Schema default value
* 4. Caller-provided default
*
* Also exposes `invalidateCache()` for reacting to settings:changed events.
*
* @param deps - Extension identity, database, and optional schema
* @returns Object with `get` and `invalidateCache` methods
*/
export function createConfigResolver(deps: ConfigResolverDeps) {
const { extensionName, database, settingsSchema } = deps;

/** Cached settings object from SQLite (null = not yet loaded). */
let settingsCache: Record<string, unknown> | null = null;

/**
* Load persisted settings from SQLite into the cache.
* Returns the cached object (may be empty `{}`).
*/
function loadSettingsCache(): Record<string, unknown> {
if (settingsCache !== null) return settingsCache;
try {
const row = database
.select({ config: schema.extensionSettings.config })
.from(schema.extensionSettings)
.where(eq(schema.extensionSettings.name, extensionName))
.get();
settingsCache = row?.config ? (JSON.parse(row.config) as Record<string, unknown>) : {};
} catch {
settingsCache = {};
}
return settingsCache;
}

/** Invalidate the settings cache so the next read fetches from SQLite. */
function invalidateCache(): void {
settingsCache = null;
}

/**
* Read a configuration value for this extension by key.
* Precedence: env var > SQLite persisted value > schema default > caller default.
*
* Values are coerced from the raw env-var string:
* `"true"`/`"false"` -> boolean, numeric strings -> number,
* JSON-shaped strings -> parsed object/array, everything else -> string.
*
* @param key - The configuration key (UPPER_SNAKE_CASE).
* @param defaultValue - Returned when no source provides a value.
* @returns The resolved value, or `undefined`.
*/
function get(key: string, defaultValue?: ConfigValue): ConfigValue | undefined {
// 1. Check environment variable (highest precedence)
const envKey = `EXT_${extensionName.toUpperCase().replace(/-/g, "_")}_${key}`;
const val = process.env[envKey];
if (typeof val !== "undefined") {
return coerceEnvValue(val, defaultValue);
}

// 2. Check SQLite persisted settings
const camelKey = envKeyToCamelCase(key);
const cached = loadSettingsCache();
if (camelKey in cached) {
return cached[camelKey] as ConfigValue;
}

// 3. Check schema default
if (settingsSchema) {
const properties = (settingsSchema as Record<string, unknown>).properties as
| Record<string, Record<string, unknown>>
| undefined;
if (properties?.[camelKey]?.default !== undefined) {
return properties[camelKey].default as ConfigValue;
}
}

// 4. Caller-provided default
return defaultValue;
}

return { get, invalidateCache };
}

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

/**
* Coerce a raw env-var string into a typed ConfigValue.
*
* @param val - The raw string from process.env
* @param defaultValue - Fallback if JSON parsing fails
* @returns The coerced value
*/
function coerceEnvValue(val: string, defaultValue?: ConfigValue): ConfigValue | undefined {
try {
if (val.toLowerCase() === "true") return true;
if (val.toLowerCase() === "false") return false;

const num = Number(val);
if (!Number.isNaN(num) && val.trim() !== "") return num;

if (val.startsWith("{") || val.startsWith("[")) {
return JSON.parse(val) as Record<string, unknown> | unknown[];
}
} catch {
return defaultValue;
}

return val;
}

/**
* Convert an UPPER_SNAKE_CASE key (e.g. "MAX_PAYLOAD_SIZE") to camelCase
* (e.g. "maxPayloadSize") for matching against schema property names.
*
* @param key - The UPPER_SNAKE_CASE key
* @returns The camelCase equivalent
*/
function envKeyToCamelCase(key: string): string {
return key.toLowerCase().replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase());
}
164 changes: 164 additions & 0 deletions src/extensions/discovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* Extension discovery and validation.
*
* Pure functions for scanning extension directories and validating that
* discovered modules conform to the {@link Extension} interface contract.
* Extracted from ExtensionRegistry to keep discovery logic testable in
* isolation without needing the full registry lifecycle.
*
* @module
*/

import { Value } from "@sinclair/typebox/value";
import { formatValidationErrors } from "@src/utils/validation";
import createLogger from "logging";
import { type Extension, ExtensionManifestSchema } from "./types";

const logger = createLogger("ExtensionRegistry");

/**
* Scan one or more extension directories for modules (subdirectories
* containing an `index.ts`). Supports both top-level extensions and
* extensions nested under `core/`.
*
* Each discovered module is dynamically imported and validated. Invalid
* modules are logged and skipped.
*
* @param extensionDirs - Directories to scan for extensions
* @returns Array of valid Extension objects
*/
export async function discoverExtensions(extensionDirs: string[]): Promise<Extension[]> {
const extensions: Extension[] = [];
const patterns = ["*/index.ts", "core/*/index.ts"];

for (const dir of extensionDirs) {
try {
for (const pattern of patterns) {
const glob = new Bun.Glob(pattern);
for (const entry of glob.scanSync({ cwd: dir, absolute: false })) {
const modulePath = `${dir}/${entry}`;
const ext = await loadExtensionModule(modulePath);
if (ext) extensions.push(ext);
}
}
} catch {
logger.warn(`Extensions directory not found or unreadable: ${dir}`);
}
}

return extensions;
}

/**
* Dynamically import a single extension module and validate its exports.
*
* @param modulePath - Absolute path to the extension's index.ts
* @returns The validated Extension object, or null if import/validation failed
*/
export async function loadExtensionModule(modulePath: string): Promise<Extension | null> {
try {
const mod = await import(modulePath);
const ext: Extension = mod.default ?? mod;

if (!validateExtension(ext, modulePath)) {
return null;
}

return ext;
} catch (err) {
logger.error(`Failed to import extension module at ${modulePath}:`, err);
return null;
}
}

/**
* Validate that a module export satisfies the {@link Extension} interface.
*
* Checks:
* - TypeBox schema conformance for the manifest
* - `settingsSchema` shape (must be a TObject with `type: "object"` and `properties`)
* - `secretsSchema` for duplicate key names
* - `ui.navigation` for duplicate routes
* - Presence of `initialize()` and `shutdown()` lifecycle methods
*
* @param ext - The candidate object to validate
* @param modulePath - Path used for error messages
* @returns `true` if the object is a valid Extension
*/
export function validateExtension(ext: unknown, modulePath: string): ext is Extension {
if (!ext || typeof ext !== "object") {
logger.error(`Extension at ${modulePath}: export is not an object`);
return false;
}

const candidate = ext as Record<string, unknown>;

// Validate manifest with TypeBox
if (!candidate.manifest || !Value.Check(ExtensionManifestSchema, candidate.manifest)) {
const errorDetail = candidate.manifest
? formatValidationErrors(ExtensionManifestSchema, candidate.manifest)
: "missing manifest";
logger.error(`Extension at ${modulePath}: invalid manifest - ${errorDetail}`);
return false;
}

// Validate settingsSchema if present (must be a TObject with type "object" and properties)
const manifest = candidate.manifest as Record<string, unknown>;
if (manifest.settingsSchema != null) {
const settingsSchema = manifest.settingsSchema as Record<string, unknown>;
if (
settingsSchema.type !== "object" ||
typeof settingsSchema.properties !== "object" ||
settingsSchema.properties === null
) {
logger.error(
`Extension at ${modulePath}: settingsSchema must be a TypeBox Type.Object() (got type="${settingsSchema.type}")`,
);
return false;
}
}

// Validate secretsSchema for duplicate key names (TypeBox catches structure, this catches duplicates)
if (manifest.secretsSchema != null) {
const secretsSchema = manifest.secretsSchema as Array<{ key: string }>;
const keyNames = new Set<string>();
const duplicates: string[] = [];
for (const entry of secretsSchema) {
if (keyNames.has(entry.key)) {
duplicates.push(entry.key);
}
keyNames.add(entry.key);
}
if (duplicates.length > 0) {
logger.warn(
`Extension at ${modulePath}: secretsSchema has duplicate key names: ${duplicates.join(", ")} - skipping secrets schema`,
);
manifest.secretsSchema = undefined;
}
}

// Check for duplicate routes within the manifest's ui.navigation array
const ui = manifest.ui as { navigation?: Array<{ route: string }> } | undefined;
if (ui?.navigation && ui.navigation.length > 0) {
const routes = new Set<string>();
for (const entry of ui.navigation) {
if (routes.has(entry.route)) {
logger.error(`Extension at ${modulePath}: duplicate route "${entry.route}" in ui.navigation`);
return false;
}
routes.add(entry.route);
}
}

if (typeof candidate.initialize !== "function") {
logger.error(`Extension at ${modulePath}: missing initialize() method`);
return false;
}

if (typeof candidate.shutdown !== "function") {
logger.error(`Extension at ${modulePath}: missing shutdown() method`);
return false;
}

return true;
}
Loading
Loading