diff --git a/.env.example b/.env.example index 8bb7366..fdeab56 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,11 @@ PASSPORT_BASE_URL= PASSPORT_PROJECT= PASSPORT_ENVIRONMENT= PASSPORT_PUBLIC_KEY= +TELEGRAM_BOT_TOKEN= +TELEGRAM_BOT_USERNAME= +TELEGRAM_BOT_SERVICE_SECRET=change-me-in-production-min32chars +TELEGRAM_LINK_TOKEN_TTL_SECONDS=600 +TELEGRAM_JWT_EXPIRES_IN=300 FRANKFURTER_BASE_URL=https://api.frankfurter.dev/v2 BRANDFETCH_API_KEY= BRANDFETCH_CLIENT_ID= diff --git a/README.md b/README.md index fbba4fe..c308a31 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ Personal income and expense tracking app. +xpenser also serves as a demonstrator for projects based on CleverBrush +Framework. See +[Cleverbrush Reference Notes](./docs/cleverbrush-reference.md) for the +framework integration patterns, security baseline, and tests that keep the app +usable as an example. + ## Local Development This setup runs the API and web app on your machine, with PostgreSQL running in diff --git a/apps/api/src/api/endpoints.test.ts b/apps/api/src/api/endpoints.test.ts index bb41f60..a95cebd 100644 --- a/apps/api/src/api/endpoints.test.ts +++ b/apps/api/src/api/endpoints.test.ts @@ -1,4 +1,7 @@ +import { generateOpenApiSpec } from '@cleverbrush/server-openapi'; +import { api } from '@xpenser/contracts'; import { describe, expect, it } from 'vitest'; +import { buildServer } from '../server.js'; import { endpoints } from './endpoints.js'; import { handlers } from './handlers/index.js'; @@ -6,6 +9,90 @@ function sortedKeys(value: object): string[] { return Object.keys(value).sort(); } +function collectEndpointPaths( + value: Record, + prefix: string[] = [] +): string[] { + return Object.entries(value).flatMap(([key, item]) => { + if ( + item && + typeof item === 'object' && + 'introspect' in item && + typeof item.introspect === 'function' + ) { + return [[...prefix, key].join('.')]; + } + return collectEndpointPaths(item as Record, [ + ...prefix, + key + ]); + }); +} + +function collectEndpointEntries( + value: Record, + prefix: string[] = [] +): Array<{ readonly name: string; readonly endpoint: { introspect(): any } }> { + return Object.entries(value).flatMap(([key, item]) => { + if ( + item && + typeof item === 'object' && + 'introspect' in item && + typeof item.introspect === 'function' + ) { + return [ + { + name: [...prefix, key].join('.'), + endpoint: item as { introspect(): any } + } + ]; + } + return collectEndpointEntries(item as Record, [ + ...prefix, + key + ]); + }); +} + +type TestOpenApiOperation = { + readonly security?: ReadonlyArray>; +}; + +type TestOpenApiDocument = { + readonly info?: { + readonly title?: string; + }; + readonly components?: { + readonly securitySchemes?: Record; + }; + readonly paths: Record< + string, + { + readonly get?: TestOpenApiOperation; + readonly post?: TestOpenApiOperation; + } + >; +}; + +function testServerConfig() { + return { + app: { url: 'http://localhost:3000' }, + api: { + publicBaseUrl: 'http://localhost:4000' + }, + jwt: { secret: 'x'.repeat(32) } + } as never; +} + +function testLogger() { + return { + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined + } as never; +} + describe('api endpoint map', () => { it('mounts every implemented handler', () => { expect(sortedKeys(endpoints)).toEqual(sortedKeys(handlers)); @@ -16,4 +103,109 @@ describe('api endpoint map', () => { ).toEqual(sortedKeys(handlers[section as keyof typeof handlers])); } }); + + it('keeps the API-local endpoint metadata tree aligned with the public contract', () => { + expect(collectEndpointPaths(endpoints).sort()).toEqual( + collectEndpointPaths( + api as unknown as Record + ).sort() + ); + }); + + it('documents every registered endpoint for generated OpenAPI output', () => { + const missingMetadata = collectEndpointEntries(endpoints).filter( + ({ endpoint }) => { + const meta = endpoint.introspect(); + return ( + !meta.summary || + !meta.description || + !meta.operationId || + !Array.isArray(meta.tags) || + meta.tags.length === 0 + ); + } + ); + + expect(missingMetadata.map(entry => entry.name)).toEqual([]); + }); + + it('generates OpenAPI security schemes for both supported credential styles', () => { + const server = buildServer(testServerConfig(), testLogger(), { + knex: {}, + db: {} + } as never); + const spec = generateOpenApiSpec({ + server, + info: { title: 'xpenser API', version: 'test' }, + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT or xpenser API key' + }, + apiKey: { + type: 'apiKey', + in: 'header', + name: 'X-API-Key' + } + } + }) as TestOpenApiDocument; + + expect(spec.components?.securitySchemes).toMatchObject({ + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT or xpenser API key' + }, + apiKey: { + type: 'apiKey', + in: 'header', + name: 'X-API-Key' + } + }); + expect(spec.paths['/api/auth/me']?.get?.security).toEqual([ + { bearerAuth: [] }, + { apiKey: [] } + ]); + expect(spec.paths['/api/auth/login']?.post?.security).toBeUndefined(); + }); + + it('serves the generated OpenAPI document from the runtime server', async () => { + const server = buildServer(testServerConfig(), testLogger(), { + knex: {}, + db: {} + } as never); + const runningServer = await server.listen(0, '127.0.0.1'); + + try { + const port = runningServer.address?.port; + expect(port).toBeTypeOf('number'); + + const response = await fetch( + `http://127.0.0.1:${port}/openapi.json` + ); + const spec = (await response.json()) as TestOpenApiDocument; + + expect(response.status).toBe(200); + expect(spec.info?.title).toBe('xpenser API'); + expect(spec.components?.securitySchemes).toMatchObject({ + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT or xpenser API key' + }, + apiKey: { + type: 'apiKey', + in: 'header', + name: 'X-API-Key' + } + }); + expect(spec.paths['/api/auth/me']?.get?.security).toEqual([ + { bearerAuth: [] }, + { apiKey: [] } + ]); + } finally { + await runningServer.close(); + } + }); }); diff --git a/apps/api/src/config.test.ts b/apps/api/src/config.test.ts index 1d7e5a8..0af2517 100644 --- a/apps/api/src/config.test.ts +++ b/apps/api/src/config.test.ts @@ -68,4 +68,22 @@ describe('API config', () => { expect(config.vendorEnrichment.enabled).toBe(true); expect(config.vendorEnrichment.timeoutMs).toBe(1234); }); + + it('rejects placeholder secrets in production', async () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('JWT_SECRET', 'change-me-in-production-min32chars'); + vi.stubEnv( + 'WEB_API_SERVICE_SECRET', + 'change-me-in-production-min32chars' + ); + vi.stubEnv( + 'TELEGRAM_BOT_SERVICE_SECRET', + 'change-me-in-production-min32chars' + ); + vi.resetModules(); + + await expect(import('./config.js')).rejects.toThrow( + 'Refusing to start with placeholder production secrets: JWT_SECRET, WEB_API_SERVICE_SECRET, TELEGRAM_BOT_SERVICE_SECRET' + ); + }); }); diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index b1094c0..e4c5101 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -2,6 +2,14 @@ import { env, parseEnv } from '@cleverbrush/env'; import { number, string } from '@cleverbrush/schema'; import { UserSessionMaxAgeSeconds } from '@xpenser/contracts/session'; +/** + * API runtime configuration parsed through `@cleverbrush/env`. + * + * All environment variables are validated and coerced once during startup, then + * the computed config object is injected into Cleverbrush handlers through DI. + * Production refuses documented placeholder secrets so example defaults cannot + * accidentally become live deployment credentials. + */ export const config = parseEnv( { nodeEnv: env('NODE_ENV', string().default('production')), diff --git a/apps/api/src/di/setup.ts b/apps/api/src/di/setup.ts index dc72c5c..31f6bc2 100644 --- a/apps/api/src/di/setup.ts +++ b/apps/api/src/di/setup.ts @@ -12,6 +12,14 @@ export type DbResources = { readonly db: AppDb; }; +/** + * Creates the shared database resources used by Cleverbrush DI. + * + * The same instrumented Knex instance backs both direct SQL and + * `@cleverbrush/orm` DbSets, so every query participates in request traces + * without duplicating connection pools. SQL text is redacted at the telemetry + * boundary to avoid leaking sensitive literals or tenant-specific identifiers. + */ export function createDbResources(config: Config, logger: Logger): DbResources { const connection = instrumentKnex( knex({ @@ -19,7 +27,8 @@ export function createDbResources(config: Config, logger: Logger): DbResources { connection: config.db.connectionString, pool: { min: 2, max: 10 }, acquireConnectionTimeout: 10_000 - }) + }), + { sanitizeStatement: () => '' } ); logger.debug('Configured application database connection pool', {}); return { @@ -28,6 +37,12 @@ export function createDbResources(config: Config, logger: Logger): DbResources { }; } +/** + * Registers request-handler dependencies for `endpoint.inject(...)`. + * + * The tokens are schema instances, matching the Cleverbrush DI convention of + * using typed schemas as service keys rather than string names or decorators. + */ export function configureDI( services: ServiceCollection, config: Config, diff --git a/apps/api/src/security/api-auth.test.ts b/apps/api/src/security/api-auth.test.ts new file mode 100644 index 0000000..1e7ec7c --- /dev/null +++ b/apps/api/src/security/api-auth.test.ts @@ -0,0 +1,118 @@ +import { signJwt } from '@cleverbrush/auth'; +import { describe, expect, it, vi } from 'vitest'; +import { + generateApiKeyMaterial, + hashApiKeySecret +} from '../application/api-keys.js'; +import type { Config } from '../config.js'; +import type { AppDb } from '../db/schemas.js'; +import { xpenserAuthScheme } from './api-auth.js'; + +const config = { + jwt: { + secret: 'x'.repeat(32) + } +} as Config; + +function authContext(headers: Record) { + return { + headers, + cookies: {}, + items: new Map() + }; +} + +describe('xpenser auth scheme', () => { + it('authenticates regular app JWT bearer tokens', async () => { + const token = signJwt( + { + sub: '42', + role: 'user', + exp: Math.floor(Date.now() / 1000) + 60 + }, + config.jwt.secret + ); + const scheme = xpenserAuthScheme(config, {} as AppDb); + + const result = await scheme.authenticate( + authContext({ authorization: `Bearer ${token}` }) + ); + + expect(result.succeeded).toBe(true); + if (!result.succeeded) { + throw new Error(result.failure); + } + expect(result.principal.value).toEqual({ + userId: 42, + role: 'user', + authType: 'jwt' + }); + }); + + it('authenticates durable API keys from the X-API-Key header', async () => { + const material = generateApiKeyMaterial(); + const apiKeyRow = { + id: 7, + userId: 42, + keyId: material.keyId, + secretHash: hashApiKeySecret(material.secret) + }; + const update = vi.fn(async () => undefined); + const db = { + apiKeys: { + where: vi.fn(() => ({ + first: vi.fn(async () => apiKeyRow), + update + })) + }, + users: { + find: vi.fn(async () => ({ id: 42, role: 'user' })) + } + } as unknown as AppDb; + const scheme = xpenserAuthScheme(config, db); + + const result = await scheme.authenticate( + authContext({ 'x-api-key': material.key }) + ); + + expect(result.succeeded).toBe(true); + if (!result.succeeded) { + throw new Error(result.failure); + } + expect(result.principal.value).toEqual({ + userId: 42, + role: 'user', + authType: 'api_key', + apiKeyId: 7 + }); + expect(result.principal.claims.get('auth_type')).toBe('api_key'); + expect(update).toHaveBeenCalledWith({ lastUsedAt: expect.any(Date) }); + }); + + it('accepts API keys sent as bearer tokens for external clients', async () => { + const material = generateApiKeyMaterial(); + const db = { + apiKeys: { + where: vi.fn(() => ({ + first: vi.fn(async () => ({ + id: 7, + userId: 42, + keyId: material.keyId, + secretHash: hashApiKeySecret(material.secret) + })), + update: vi.fn(async () => undefined) + })) + }, + users: { + find: vi.fn(async () => ({ id: 42, role: 'user' })) + } + } as unknown as AppDb; + const scheme = xpenserAuthScheme(config, db); + + const result = await scheme.authenticate( + authContext({ authorization: `Bearer ${material.key}` }) + ); + + expect(result.succeeded).toBe(true); + }); +}); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 378305b..6b45946 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -6,7 +6,7 @@ import { type Middleware, mapHandlers } from '@cleverbrush/server'; -import { serveOpenApi } from '@cleverbrush/server-openapi'; +import { createOpenApiEndpoint } from '@cleverbrush/server-openapi'; import { endpoints } from './api/endpoints.js'; import { handlers } from './api/handlers/index.js'; import type { Config } from './config.js'; @@ -14,6 +14,14 @@ import { configureDI, type DbResources } from './di/setup.js'; import { McpEndpoint, mcpHandler } from './mcp/endpoint.js'; import { xpenserAuthScheme } from './security/api-auth.js'; +/** + * CORS middleware for the public API surface. + * + * The allowed origin is intentionally the configured web app origin, because + * browser traffic normally reaches the API through the Next.js app or the + * `/external-api` proxy. Non-browser clients can still use bearer/API-key auth + * without relying on CORS. + */ function corsMiddleware(config: Config): Middleware { return async (ctx, next) => { ctx.response.setHeader('Access-Control-Allow-Origin', config.app.url); @@ -48,6 +56,11 @@ export function buildServer( correlationResponseHeader: false }); + /** + * Middleware order matters for the reference app: + * tracing opens the server span first, then CORS/logging/DI/auth run inside + * that span so logs and database spans can correlate with the request. + */ const server = createServer({ maxBodySize: 20 * 1024 * 1024 }) @@ -64,35 +77,38 @@ export function buildServer( .withHealthcheck() .useBatching(); - server.use( - serveOpenApi({ - server, - info: { - title: 'xpenser API', - version: '0.1.0', - description: - 'Schema-first income and expense tracking API built with Cleverbrush.' + const openApi = createOpenApiEndpoint({ + server, + info: { + title: 'xpenser API', + version: '0.1.0', + description: + 'Schema-first income and expense tracking API built with Cleverbrush.' + }, + servers: [ + { + url: config.api.publicBaseUrl, + description: 'Configured API base URL' + } + ], + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT or xpenser API key' }, - servers: [ - { - url: config.api.publicBaseUrl, - description: 'Configured API base URL' - } - ], - securitySchemes: { - bearerAuth: { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT or xpenser API key' - }, - apiKey: { - type: 'apiKey', - in: 'header', - name: 'X-API-Key' - } + apiKey: { + type: 'apiKey', + in: 'header', + name: 'X-API-Key' } - }) - ); + } + }); + + // Register OpenAPI as a first-class endpoint. Cleverbrush middleware runs + // after route matching, so an unmatched `/openapi.json` request would never + // reach `serveOpenApi()`. + server.handle(openApi.endpoint, openApi.handler); server.handle(McpEndpoint, mcpHandler); server.handleAll(mapHandlers(endpoints, handlers)); diff --git a/apps/telegram-bot/src/config.test.ts b/apps/telegram-bot/src/config.test.ts new file mode 100644 index 0000000..1bc9ee4 --- /dev/null +++ b/apps/telegram-bot/src/config.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const PLACEHOLDER_SECRET = 'change-me-in-production-min32chars'; + +async function importConfig() { + vi.resetModules(); + return import('./config.js'); +} + +describe('Telegram bot config', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it('allows the documented placeholder secret outside production', async () => { + vi.stubEnv('NODE_ENV', 'development'); + vi.stubEnv('TELEGRAM_BOT_TOKEN', 'telegram-token'); + vi.stubEnv('TELEGRAM_BOT_USERNAME', 'xpenser_bot'); + + const { botConfig } = await importConfig(); + + expect(botConfig.serviceSecret).toBe(PLACEHOLDER_SECRET); + }); + + it('rejects the placeholder service secret in production', async () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('TELEGRAM_BOT_TOKEN', 'telegram-token'); + vi.stubEnv('TELEGRAM_BOT_USERNAME', 'xpenser_bot'); + vi.stubEnv('TELEGRAM_BOT_SERVICE_SECRET', PLACEHOLDER_SECRET); + + await expect(importConfig()).rejects.toThrow( + 'Refusing to start with placeholder production secret: TELEGRAM_BOT_SERVICE_SECRET' + ); + }); +}); diff --git a/apps/telegram-bot/src/config.ts b/apps/telegram-bot/src/config.ts index 5967ca5..d2f40cb 100644 --- a/apps/telegram-bot/src/config.ts +++ b/apps/telegram-bot/src/config.ts @@ -1,6 +1,15 @@ import { env, parseEnv } from '@cleverbrush/env'; import { string } from '@cleverbrush/schema'; +const PLACEHOLDER_SECRET = 'change-me-in-production-min32chars'; + +/** + * Parsed Telegram bot runtime configuration. + * + * The bot intentionally uses the same `@cleverbrush/env` pattern as the web + * and API apps so required secrets, coerced values, and production guardrails + * are enforced at startup instead of at the first Telegram update. + */ export const botConfig = parseEnv({ nodeEnv: env('NODE_ENV', string().default('production')), apiBaseUrl: env('API_BASE_URL', string().default('http://localhost:4000')), @@ -20,7 +29,7 @@ export const botConfig = parseEnv({ }, serviceSecret: env( 'TELEGRAM_BOT_SERVICE_SECRET', - string().minLength(32).default('change-me-in-production-min32chars') + string().minLength(32).default(PLACEHOLDER_SECRET) ), logLevel: env( 'LOG_LEVEL', @@ -37,4 +46,13 @@ export const botConfig = parseEnv({ ) }); +if ( + botConfig.nodeEnv === 'production' && + botConfig.serviceSecret === PLACEHOLDER_SECRET +) { + throw new Error( + 'Refusing to start with placeholder production secret: TELEGRAM_BOT_SERVICE_SECRET' + ); +} + export type BotConfig = typeof botConfig; diff --git a/apps/telegram-bot/src/tracing.test.ts b/apps/telegram-bot/src/tracing.test.ts index de3cd67..73c585c 100644 --- a/apps/telegram-bot/src/tracing.test.ts +++ b/apps/telegram-bot/src/tracing.test.ts @@ -3,7 +3,8 @@ import { telegramCallbackAction, telegramCommand, telegramSpanAttributes, - telegramSpanName + telegramSpanName, + traceTelegramUpdate } from './tracing.js'; describe('telegram tracing helpers', () => { @@ -45,4 +46,22 @@ describe('telegram tracing helpers', () => { }); expect(Object.values(attributes)).not.toContain('cat:123'); }); + + it('returns handler results and propagates handler errors from traced updates', async () => { + await expect( + traceTelegramUpdate( + { updateType: 'command', command: 'start' }, + async () => 'ok' + ) + ).resolves.toBe('ok'); + + await expect( + traceTelegramUpdate( + { updateType: 'message' }, + async (): Promise => { + throw new Error('handler failed'); + } + ) + ).rejects.toThrow('handler failed'); + }); }); diff --git a/apps/telegram-bot/src/tracing.ts b/apps/telegram-bot/src/tracing.ts index 5bfe8ff..da46708 100644 --- a/apps/telegram-bot/src/tracing.ts +++ b/apps/telegram-bot/src/tracing.ts @@ -17,12 +17,22 @@ export type TelegramUpdateSpanInfo = { const tracer = trace.getTracer('xpenser.telegram-bot'); +/** + * Extracts a low-cardinality Telegram command name for span names and + * attributes without retaining command arguments such as deep-link tokens. + */ export function telegramCommand(text: string | undefined): string { const match = (text ?? '').trim().match(/^\/([a-zA-Z0-9_]+)(?:@\S+)?/); const command = match?.[1]; return command?.toLowerCase() ?? 'unknown'; } +/** + * Maps callback payloads to safe action names. + * + * Callback payloads can contain category IDs or other user data, so only the + * action family is recorded in telemetry. + */ export function telegramCallbackAction(data: string | undefined): string { if (!data) { return 'unknown'; diff --git a/apps/web/lib/config.test.ts b/apps/web/lib/config.test.ts new file mode 100644 index 0000000..6447842 --- /dev/null +++ b/apps/web/lib/config.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const PLACEHOLDER_SECRET = 'change-me-in-production-min32chars'; + +describe('web config secret guards', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it('rejects the placeholder Auth.js secret in production', async () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('NEXTAUTH_SECRET', PLACEHOLDER_SECRET); + vi.resetModules(); + + const { getNextAuthSecret } = await import('./config'); + + expect(() => getNextAuthSecret()).toThrow( + 'Refusing to start with placeholder production secret: NEXTAUTH_SECRET' + ); + }); + + it('rejects the placeholder web-to-API service secret in production', async () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('WEB_API_SERVICE_SECRET', PLACEHOLDER_SECRET); + vi.resetModules(); + + const { getWebApiServiceSecret } = await import('./config'); + + expect(() => getWebApiServiceSecret()).toThrow( + 'Refusing to start with placeholder production secret: WEB_API_SERVICE_SECRET' + ); + }); +}); diff --git a/apps/web/lib/config.ts b/apps/web/lib/config.ts index 6c80be0..ae632bb 100644 --- a/apps/web/lib/config.ts +++ b/apps/web/lib/config.ts @@ -2,6 +2,11 @@ import { env, parseEnv } from '@cleverbrush/env'; import { string } from '@cleverbrush/schema'; import { GoogleSignInModes, resolveGoogleSignInProvider } from './google-auth'; +/** + * Web runtime configuration parsed with the same `@cleverbrush/env` pattern as + * the API. Only server-side modules should import this object; browser-exposed + * configuration must stay behind explicit `NEXT_PUBLIC_*` variables. + */ export const webConfig = parseEnv({ nodeEnv: env('NODE_ENV', string().default('production')), appUrl: env('APP_URL', string().default('http://localhost:3000')), @@ -47,6 +52,12 @@ export function getGoogleSignInProvider() { const PLACEHOLDER_SECRET = 'change-me-in-production-min32chars'; +/** + * Reads and validates the Auth.js signing secret at call time. + * + * Auth.js expects this value when the auth module is initialized, but parsing it + * lazily keeps tests and routes that do not touch auth from requiring a secret. + */ export function getNextAuthSecret(): string { const { nextAuthSecret } = parseEnv({ nextAuthSecret: env('NEXTAUTH_SECRET', string().minLength(32)) @@ -64,6 +75,9 @@ export function getNextAuthSecret(): string { return nextAuthSecret; } +/** + * Reads the private shared secret used by trusted web-to-API session refreshes. + */ export function getWebApiServiceSecret(): string { const { webApiServiceSecret } = parseEnv({ webApiServiceSecret: env( diff --git a/docs/cleverbrush-reference.md b/docs/cleverbrush-reference.md new file mode 100644 index 0000000..bf18549 --- /dev/null +++ b/docs/cleverbrush-reference.md @@ -0,0 +1,82 @@ +# Cleverbrush Reference Notes + +xpenser is both a usable personal finance app and a reference implementation for +projects based on CleverBrush Framework. This document points to the patterns +worth copying and the checks that keep those patterns from drifting. + +## Architecture Map + +- `packages/contracts` defines the public API with `@cleverbrush/schema` and + `@cleverbrush/server/contract`. These schemas are the source of truth for + TypeScript types, request validation, OpenAPI output, form bindings, and typed + clients. +- `apps/api/src/api/endpoints.ts` enriches the shared contract with + server-only metadata: DI tokens, summaries, descriptions, tags, and operation + IDs. `apps/api/src/api/handlers` contains the matching handler tree. +- `apps/api/src/server.ts` builds the Cleverbrush server with tracing first, + CORS, structured request logging, DI, authentication, authorization, + healthchecks, batching, OpenAPI, MCP, and all contract handlers. +- `packages/client` wraps `@cleverbrush/client` with the app middleware stack: + OTel context propagation, retry, timeout, dedupe, tag cache invalidation, and + root-path batching. +- `packages/ui/src/forms/react-form-provider.tsx` registers xpenser UI renderers + for `@cleverbrush/react-form`, so app forms bind fields with property + selectors instead of string paths. +- `apps/api/src/db/schemas.ts` defines typed ORM entities with + `@cleverbrush/orm`; `apps/api/src/di/setup.ts` exposes the instrumented Knex + pool and ORM context through Cleverbrush DI. + +## Framework Usage Rules + +- Reuse exported schema constants when a type appears in more than one endpoint. + Use `.schemaName()` for object-level components that should become OpenAPI + `$ref`s. +- Do not clone a named schema with `.describe()`, `.optional()`, `.nullable()`, + or `.default()` and then reuse it elsewhere. The OpenAPI registry requires a + single object reference per schema name. Leaf fragments are intentionally left + unnamed when they need per-property descriptions. +- Keep the public contract tree, API endpoint metadata tree, and handler tree in + the same shape. The endpoint-map tests enforce this. +- Put `tracingMiddleware()` before other API middleware so logs and database + spans correlate with the request span. +- Use `ActionResult` helpers in handlers for expected API statuses; reserve + thrown errors for unexpected failures or framework `HttpError` cases. +- Keep credential-bearing integrations behind server-side modules. Browser code + should call Server Actions or route handlers rather than the API directly. + +## Security Baseline + +- Local `.env.example` values are safe for development only. API, web, and + Telegram bot startup all refuse documented placeholder secrets in production. +- Passwords use scrypt with per-password salts. API keys, Telegram link tokens, + and email confirmation tokens are stored as hashes. +- The API accepts either short-lived JWTs or durable user API keys through one + composite Cleverbrush auth scheme. MCP access additionally requires an API-key + principal. +- Knex spans are emitted for database visibility, but SQL text is redacted at + the instrumentation boundary. +- Telegram tracing records low-cardinality command/action names instead of raw + callback payloads or deep-link tokens. + +## Tests To Keep + +- Contract authorization tests in `packages/contracts/src/api.test.ts`. +- Endpoint drift and OpenAPI generation tests in + `apps/api/src/api/endpoints.test.ts`. +- Config guard tests for API, web, and Telegram bot production secrets. +- Client middleware tests for batching, retry, timeout, dedupe, cache tags, and + tracing order. +- Form provider tests that prove Cleverbrush schema fields resolve to the + expected xpenser UI controls. +- E2E workflow tests for authenticated app behavior and preview validation. + +## Adding New Features + +1. Add or update the schema in `packages/contracts/src/schemas.ts`. +2. Add the endpoint to `packages/contracts/src/api.ts` with auth and cache tags. +3. Enrich the endpoint in `apps/api/src/api/endpoints.ts` with DI and OpenAPI + metadata. +4. Add the matching handler in `apps/api/src/api/handlers`. +5. Use `createXpenserClient()` from server-side web code or external clients. +6. Add focused tests for schema validation, handler behavior, contract metadata, + and any changed UI flow. diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 2e5c984..ec76e21 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -71,4 +71,44 @@ describe('createXpenserClient', () => { timeout: 60_000 }); }); + + it('orders framework middlewares from tracing through cache and batching', () => { + createXpenserClient({ baseUrl: 'http://api:4000' }); + + expect(middlewareMocks.createClient).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + middlewares: [ + { name: 'tracing' }, + { name: 'retry' }, + { name: 'timeout' }, + { name: 'dedupe' }, + { name: 'cacheTags' }, + { name: 'batching' } + ] + }) + ); + expect(middlewareMocks.batching).toHaveBeenCalledWith({ + maxSize: 10, + windowMs: 10 + }); + }); + + it('skips batching when the base URL includes an API proxy path', () => { + createXpenserClient({ baseUrl: 'http://localhost:3000/external-api' }); + + expect(middlewareMocks.batching).not.toHaveBeenCalled(); + expect(middlewareMocks.createClient).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + middlewares: [ + { name: 'tracing' }, + { name: 'retry' }, + { name: 'timeout' }, + { name: 'dedupe' }, + { name: 'cacheTags' } + ] + }) + ); + }); }); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 27cd8f3..8768501 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -39,6 +39,13 @@ function hasBasePath(baseUrl: string): boolean { * * Use this factory from server code and Server Components. Browser components * should submit to Server Actions instead of calling the API directly. + * + * The middleware stack mirrors the framework recommendations: + * tracing propagates OTel context, retry/timeout handle transient failures, + * dedupe and tag caching reduce repeated reads, and batching is enabled only + * when the API base URL points at the API root. Batching is skipped for proxied + * base paths such as `/external-api` because the batch endpoint is mounted at + * the API root by `ServerBuilder.useBatching()`. */ export function createXpenserClient(options: XpenserClientOptions) { const batchingMiddleware = hasBasePath(options.baseUrl) diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index 04cdfa9..7132a08 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -98,6 +98,15 @@ const apiKeys = endpoint .resource('/api/users/me/api-keys') .authorize(PrincipalSchema); +/** + * Public xpenser HTTP API contract. + * + * This is the single contract shared by the API server and typed clients. The + * server enriches these endpoint builders with DI, summaries, and operation IDs + * in `apps/api/src/api/endpoints.ts`, while consumers import this contract to + * get request, response, route-parameter, cache-tag, and authorization metadata + * without code generation. + */ export const api = defineApi({ auth: { register: endpoint diff --git a/packages/contracts/src/limits.ts b/packages/contracts/src/limits.ts index bb50e3d..b7f90af 100644 --- a/packages/contracts/src/limits.ts +++ b/packages/contracts/src/limits.ts @@ -1,3 +1,10 @@ +/** + * Persisted text-field limits shared by contracts, UI inputs, and database + * migrations. + * + * Keeping these values in the contracts package prevents form max lengths, + * Cleverbrush schema validation, and database column sizes from drifting. + */ export const FieldLimits = { apiKeyName: 120, brandfetchBrandId: 100, @@ -26,6 +33,9 @@ export const FieldLimits = { vendorSearch: 160 } as const; +/** + * Limits for multimodal transaction scan uploads and generated scan drafts. + */ export const TransactionScanLimits = { maxImageBytes: 10 * 1024 * 1024, uploadChunkBytes: 384 * 1024 diff --git a/packages/contracts/src/schemas.ts b/packages/contracts/src/schemas.ts index 5698c38..46dc268 100644 --- a/packages/contracts/src/schemas.ts +++ b/packages/contracts/src/schemas.ts @@ -4,6 +4,7 @@ import { date, enumOf, type InferType, + nul, number, object, string, @@ -15,15 +16,13 @@ export const CurrencyCodeSchema = string() .required('currency is required') .nonempty('currency is required') .matches(/^[A-Z]{3}$/, 'currency must be a 3-letter ISO 4217 code') - .describe('ISO 4217 currency code, for example USD or EUR.') - .schemaName('CurrencyCode'); + .describe('ISO 4217 currency code, for example USD or EUR.'); export const CountryCodeSchema = string() .required('country is required') .nonempty('country is required') .matches(/^[A-Z]{2}$/, 'country must be a 2-letter ISO 3166-1 code') - .describe('ISO 3166-1 alpha-2 country code, for example US or UA.') - .schemaName('CountryCode'); + .describe('ISO 3166-1 alpha-2 country code, for example US or UA.'); export const TimeZoneSchema = string() .required('timezone is required') @@ -44,8 +43,9 @@ export const TimeZoneSchema = string() }; } }) - .describe('IANA time zone identifier, for example UTC or America/New_York.') - .schemaName('TimeZone'); + .describe( + 'IANA time zone identifier, for example UTC or America/New_York.' + ); function isHttpsUrl(value: string): boolean { try { @@ -57,27 +57,35 @@ function isHttpsUrl(value: string): boolean { export const CategoryTypeSchema = enumOf('expense', 'income') .required('category type is required') - .describe('Whether a category is used for expenses or income.') - .schemaName('CategoryType'); + .describe('Whether a category is used for expenses or income.'); export const CategoryKindSchema = enumOf('normal', 'offset') .default('normal') .describe( 'Whether transactions in this category report on the same or opposite side.' - ) - .schemaName('CategoryKind'); - -export const PeriodSchema = enumOf('day', 'week', 'month', 'quarter', 'year') - .describe('Dashboard reporting period.') - .schemaName('Period'); - -export const StatsGroupBySchema = enumOf('hour', 'day', 'week', 'month') - .describe('Stats trend grouping.') - .schemaName('StatsGroupBy'); + ); -export const CategoryTrendGroupBySchema = enumOf('day', 'week', 'month', 'year') - .describe('Category trend grouping.') - .schemaName('CategoryTrendGroupBy'); +export const PeriodSchema = enumOf( + 'day', + 'week', + 'month', + 'quarter', + 'year' +).describe('Dashboard reporting period.'); + +export const StatsGroupBySchema = enumOf( + 'hour', + 'day', + 'week', + 'month' +).describe('Stats trend grouping.'); + +export const CategoryTrendGroupBySchema = enumOf( + 'day', + 'week', + 'month', + 'year' +).describe('Category trend grouping.'); export const CategoryTrendRangeSchema = enumOf( 'last-30-days', @@ -86,9 +94,7 @@ export const CategoryTrendRangeSchema = enumOf( 'last-12-months', 'all-time', 'custom' -) - .describe('Category trend timeframe.') - .schemaName('CategoryTrendRange'); +).describe('Category trend timeframe.'); export const StatsTimeframeSchema = enumOf( 'this-week', @@ -97,13 +103,11 @@ export const StatsTimeframeSchema = enumOf( 'last-month', 'last-30-days', 'custom' -) - .describe('Stats reporting timeframe.') - .schemaName('StatsTimeframe'); +).describe('Stats reporting timeframe.'); -export const SortDirectionSchema = enumOf('asc', 'desc') - .describe('Sort direction.') - .schemaName('SortDirection'); +export const SortDirectionSchema = enumOf('asc', 'desc').describe( + 'Sort direction.' +); const decimalNumber = () => number().clearIsInteger(); @@ -125,9 +129,7 @@ export const ImageMimeTypeSchema = enumOf( 'image/jpeg', 'image/png', 'image/webp' -) - .describe('Supported image MIME type.') - .schemaName('ImageMimeType'); +).describe('Supported image MIME type.'); export const PrincipalSchema = object({ /** Authenticated user identifier encoded in the API JWT. */ @@ -488,7 +490,7 @@ export const CreateApiKeyResponseSchema = object({ 'Plaintext API key. It is returned only when the key is created.' ), /** Persisted API key metadata. */ - apiKey: ApiKeySchema.describe('Persisted API key metadata.') + apiKey: ApiKeySchema }).schemaName('CreateApiKeyResponse'); export const UpdateUserPreferenceBodySchema = object({ @@ -602,16 +604,12 @@ export const LinkTelegramAccountBodySchema = object({ .maxLength(FieldLimits.telegramLinkToken, 'link token is too long') .describe('Random one-time token from the Telegram deep link payload.'), /** Telegram account to link to the xpenser account that owns the token. */ - telegramUser: TelegramUserBodySchema.describe( - 'Telegram account to link to the xpenser account that owns the token.' - ) + telegramUser: TelegramUserBodySchema }).schemaName('LinkTelegramAccountBody'); export const TelegramTokenBodySchema = object({ /** Telegram account requesting a short-lived xpenser API token. */ - telegramUser: TelegramUserBodySchema.describe( - 'Telegram account requesting a short-lived xpenser API token.' - ) + telegramUser: TelegramUserBodySchema }).schemaName('TelegramTokenBody'); export const LinkTelegramAccountResponseSchema = object({ @@ -620,9 +618,7 @@ export const LinkTelegramAccountResponseSchema = object({ /** Connected xpenser user email address. */ email: string().describe('Connected xpenser user email address.'), /** Current Telegram connection status. */ - telegram: TelegramConnectionStatusSchema.describe( - 'Current Telegram connection status.' - ) + telegram: TelegramConnectionStatusSchema }).schemaName('LinkTelegramAccountResponse'); export const CurrencySchema = object({ @@ -798,7 +794,7 @@ export const VendorEnrichmentStatusSchema = enumOf( 'success', 'not_found', 'failed' -).schemaName('VendorEnrichmentStatus'); +); export const VendorSchema = object({ /** Unique vendor identifier. */ @@ -1307,13 +1303,13 @@ export const TransactionScanDocumentKindSchema = enumOf( 'invoice', 'receipt', 'other' -) - .describe('Type of uploaded transaction source inferred from the image.') - .schemaName('TransactionScanDocumentKind'); +).describe('Type of uploaded transaction source inferred from the image.'); -export const TransactionScanConfidenceSchema = enumOf('high', 'medium', 'low') - .describe('Model confidence for a scanned transaction field.') - .schemaName('TransactionScanConfidence'); +export const TransactionScanConfidenceSchema = enumOf( + 'high', + 'medium', + 'low' +).describe('Model confidence for a scanned transaction field.'); export const TransactionScanSuggestedCategorySchema = object({ /** Suggested category name when no existing category fits. */ @@ -1359,10 +1355,9 @@ export const TransactionScanDraftSchema = object({ 'Existing category identifier selected by the scanner, when available.' ), /** Scanner suggestion for a category that does not exist yet. */ - suggestedCategory: - TransactionScanSuggestedCategorySchema.nullable().describe( - 'Scanner suggestion for a category that does not exist yet.' - ), + suggestedCategory: union(TransactionScanSuggestedCategorySchema) + .or(nul()) + .describe('Scanner suggestion for a category that does not exist yet.'), /** Currency used for the scanned amount, when visible. */ currency: CurrencyCodeSchema.nullable().describe( 'Currency used for the scanned amount, when visible.' @@ -1399,9 +1394,7 @@ export const TransactionScanDraftSchema = object({ 'Text from the image that supports this draft.' ), /** Confidence by scanned field. */ - confidence: TransactionScanFieldConfidenceSchema.describe( - 'Confidence by scanned field.' - ), + confidence: TransactionScanFieldConfidenceSchema, /** Existing transactions that may already represent this draft. */ possibleDuplicateTransactionIds: array(number()).describe( 'Existing transactions that may already represent this draft.' @@ -1486,9 +1479,7 @@ export const TransactionScanProgressStageSchema = enumOf( 'saving', 'complete', 'failed' -) - .describe('Current scanner job stage.') - .schemaName('TransactionScanProgressStage'); +).describe('Current scanner job stage.'); export const TransactionScanProgressEventSchema = object({ /** Short-lived scan job identifier. */ @@ -1502,19 +1493,16 @@ export const TransactionScanProgressEventSchema = object({ /** Approximate scan progress from 0 to 100. */ progress: number().describe('Approximate scan progress from 0 to 100.'), /** Final scan result when the job completed successfully. */ - scan: TransactionScanResponseSchema.nullable().describe( - 'Final scan result when the job completed successfully.' - ), + scan: union(TransactionScanResponseSchema) + .or(nul()) + .describe('Final scan result when the job completed successfully.'), /** Safe user-facing failure message when the job failed. */ error: string() .nullable() .describe('Safe user-facing failure message when the job failed.') }).schemaName('TransactionScanProgressEvent'); -export const TransactionScanDecisionSchema = enumOf( - 'confirmed', - 'discarded' -).schemaName('TransactionScanDecision'); +export const TransactionScanDecisionSchema = enumOf('confirmed', 'discarded'); export const TransactionScanCorrectedTransactionSchema = object({ /** Confirmed category identifier. */ @@ -1554,13 +1542,16 @@ export const TransactionScanDecisionBodySchema = object({ .optional() .describe('Vendor created inline for this draft, when applicable.'), /** Final user-corrected values, when confirmed. */ - correctedTransaction: TransactionScanCorrectedTransactionSchema.nullable() + correctedTransaction: union(TransactionScanCorrectedTransactionSchema) + .or(nul()) .optional() .describe('Final user-corrected values, when confirmed.'), /** Original scan image, stored once for confirmed transactions. */ - attachment: TransactionScanAttachmentBodySchema.optional().describe( - 'Original scan image, stored once for confirmed transactions.' - ) + attachment: union(TransactionScanAttachmentBodySchema) + .optional() + .describe( + 'Original scan image, stored once for confirmed transactions.' + ) }).schemaName('TransactionScanDecisionBody'); export const TransactionScanImageResponseSchema = object({ @@ -1941,13 +1932,9 @@ export const StatsOverviewSchema = object({ /** Comparison totals for matching prior periods. */ comparison: object({ /** Matching previous period totals. */ - previousPeriod: StatsComparisonSchema.describe( - 'Matching previous period totals.' - ), + previousPeriod: StatsComparisonSchema, /** Same selected period one year earlier. */ - previousYear: StatsComparisonSchema.describe( - 'Same selected period one year earlier.' - ) + previousYear: StatsComparisonSchema }).describe('Comparison totals for matching prior periods.') }).schemaName('StatsOverview'); @@ -1986,9 +1973,7 @@ export const DashboardWindowItemSchema = object({ /** Stable local date key for the period start. */ date: string().describe('Stable local date key for the period start.'), /** Summary for the matching dashboard period. */ - summary: DashboardSummarySchema.describe( - 'Summary for the matching dashboard period.' - ) + summary: DashboardSummarySchema }).schemaName('DashboardWindowItem'); export const DashboardWindowResponseSchema = object({ @@ -2002,9 +1987,7 @@ export const StatsWindowItemSchema = object({ /** Stable local date key for the period start. */ date: string().describe('Stable local date key for the period start.'), /** Stats overview for the matching dashboard period. */ - overview: StatsOverviewSchema.describe( - 'Stats overview for the matching dashboard period.' - ) + overview: StatsOverviewSchema }).schemaName('StatsWindowItem'); export const StatsWindowResponseSchema = object({ diff --git a/packages/ui/src/forms/react-form-provider.test.tsx b/packages/ui/src/forms/react-form-provider.test.tsx index 1fb4bda..ab73a8f 100644 --- a/packages/ui/src/forms/react-form-provider.test.tsx +++ b/packages/ui/src/forms/react-form-provider.test.tsx @@ -2,10 +2,55 @@ * @vitest-environment jsdom */ +import { Field as SchemaField, useSchemaForm } from '@cleverbrush/react-form'; +import { boolean, number, object, string } from '@cleverbrush/schema'; import { render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { XpenserFormProvider } from './react-form-provider.js'; +const ExampleSchema = object({ + email: string().email(), + password: string(), + amount: number(), + enabled: boolean() +}); + +function ExampleForm() { + const form = useSchemaForm(ExampleSchema); + + return ( + + field.email} + form={form} + label="Email" + name="email" + variant="email" + /> + field.password} + form={form} + label="Password" + name="password" + variant="password" + /> + field.amount} + form={form} + label="Amount" + name="amount" + /> + field.enabled} + form={form} + label="Enabled" + name="enabled" + variant="checkbox" + /> + + ); +} + describe('XpenserFormProvider', () => { it('renders children inside the form system provider', () => { render( @@ -16,4 +61,21 @@ describe('XpenserFormProvider', () => { expect(screen.getByText('Form content')).toBeTruthy(); }); + + it('registers type and variant renderers for schema-driven fields', () => { + render(); + + expect(screen.getByLabelText('Email').getAttribute('type')).toBe( + 'email' + ); + expect(screen.getByLabelText('Password').getAttribute('type')).toBe( + 'password' + ); + expect(screen.getByLabelText('Amount').getAttribute('type')).toBe( + 'number' + ); + expect(screen.getByLabelText('Enabled').getAttribute('type')).toBe( + 'checkbox' + ); + }); }); diff --git a/packages/ui/src/forms/react-form-provider.tsx b/packages/ui/src/forms/react-form-provider.tsx index 750eff5..fb7feb7 100644 --- a/packages/ui/src/forms/react-form-provider.tsx +++ b/packages/ui/src/forms/react-form-provider.tsx @@ -19,10 +19,19 @@ import { import { Textarea } from '../components/textarea.js'; export type SelectRendererOption = { + /** Human-readable option label rendered inside the select menu. */ readonly label: React.ReactNode; + /** Form value passed back to `@cleverbrush/react-form` on selection. */ readonly value: string; }; +/** + * Extra props understood by the xpenser select renderer. + * + * These props are passed through `Field`'s `fieldProps` escape hatch while the + * field binding itself remains type-safe through Cleverbrush property + * selectors. + */ export type SelectRendererFieldProps = { readonly ariaLabel?: string; readonly disabled?: boolean; @@ -43,6 +52,7 @@ export type CheckboxRendererFieldProps = { ) => void; }; +/** Props accepted by the date/time renderer variant. */ export type DateTimeRendererFieldProps = React.InputHTMLAttributes & { readonly onValueChange?: ( @@ -299,6 +309,13 @@ const renderers = { 'date:datetime-local': dateTimeRenderer }; +/** + * Registers xpenser UI renderers for `@cleverbrush/react-form`. + * + * App forms can use ` field.name} />` and rely on + * this provider to pick the matching input, select, checkbox, textarea, or + * datetime renderer from the Cleverbrush schema descriptor. + */ export function XpenserFormProvider({ children }: {