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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
192 changes: 192 additions & 0 deletions apps/api/src/api/endpoints.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,98 @@
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';

function sortedKeys(value: object): string[] {
return Object.keys(value).sort();
}

function collectEndpointPaths(
value: Record<string, unknown>,
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<string, unknown>, [
...prefix,
key
]);
});
}

function collectEndpointEntries(
value: Record<string, unknown>,
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<string, unknown>, [
...prefix,
key
]);
});
}

type TestOpenApiOperation = {
readonly security?: ReadonlyArray<Record<string, readonly string[]>>;
};

type TestOpenApiDocument = {
readonly info?: {
readonly title?: string;
};
readonly components?: {
readonly securitySchemes?: Record<string, unknown>;
};
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));
Expand All @@ -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<string, unknown>
).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();
}
});
});
18 changes: 18 additions & 0 deletions apps/api/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
});
});
8 changes: 8 additions & 0 deletions apps/api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')),
Expand Down
17 changes: 16 additions & 1 deletion apps/api/src/di/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,23 @@ 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({
client: 'pg',
connection: config.db.connectionString,
pool: { min: 2, max: 10 },
acquireConnectionTimeout: 10_000
})
}),
{ sanitizeStatement: () => '<redacted>' }
);
logger.debug('Configured application database connection pool', {});
return {
Expand All @@ -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,
Expand Down
Loading
Loading