Skip to content
Open
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
10 changes: 10 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ DB_USER=myfans
DB_PASSWORD= # REQUIRED — use a strong random password
DB_NAME=myfans

# Set to `true` locally so TypeORM can create/update tables (e.g. audit_logs).
# Production should use migrations; leave unset or false in prod.
TYPEORM_SYNC=false

# -----------------------------------------------------------------------------
# Audit log (optional; required for GET /v1/admin/audit)
# -----------------------------------------------------------------------------
# Shared secret for admin audit queries. Sent as header: x-admin-audit-key
AUDIT_ADMIN_API_KEY=

# -----------------------------------------------------------------------------
# Authentication (REQUIRED)
# Generate with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
Expand Down
10 changes: 10 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ $ npm run start:dev
$ npm run start:prod
```

## Chain replay CLI

Recover missed historical chain events into DB:

```bash
$ npm run replay:chain-events -- --start-cursor <cursor> --end-cursor <cursor> --dry-run
```

Detailed runbook: `docs/CHAIN_EVENT_REPLAY.md`

## Run tests

```bash
Expand Down
58 changes: 58 additions & 0 deletions backend/docs/CHAIN_EVENT_REPLAY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Chain Event Replay CLI

## Purpose

Replay historical Stellar chain events into PostgreSQL to recover indexer gaps safely.

## Command

```bash
npm run replay:chain-events -- --start-cursor <cursor> [--end-cursor <cursor>] [--dry-run] [--limit 200]
```

## Required Environment

- `DATABASE_URL`: PostgreSQL connection string
- `HORIZON_URL` (optional): Horizon base URL (defaults to `https://horizon-testnet.stellar.org`)

## Parameters

- `--start-cursor` (required): Inclusive starting cursor for replay.
- `--end-cursor` (optional): Inclusive ending cursor; replay stops once this cursor is reached.
- `--dry-run` (optional): Reads and evaluates events but does not write to DB.
- `--limit` (optional): Page size per request (1..200, default `200`).

## Idempotency and Safety

- Replayed records are stored in table `chain_event_replay`.
- Each record is keyed by `paging_token` and deduplicated with `ON CONFLICT DO NOTHING`.
- Re-running the same range is safe; duplicates are skipped and reported in summary output.

## Dry-Run Workflow (Recommended)

1. Validate scope and expected volume:

```bash
npm run replay:chain-events -- --start-cursor 123 --end-cursor 999 --dry-run
```

2. Execute replay:

```bash
npm run replay:chain-events -- --start-cursor 123 --end-cursor 999
```

## Output

The CLI prints JSON summary:

- `startCursor`
- `endCursor`
- `finalCursor`
- `pages`
- `fetched`
- `inserted`
- `duplicates`
- `dryRun`

Use `finalCursor` as a checkpoint for subsequent replay windows.
4 changes: 0 additions & 4 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:e2e": "jest --config ./test/jest-e2e.json",
"replay:chain-events": "ts-node src/cli/replay-chain-events.ts"
},
"dependencies": {
"@nestjs/common": "^11.1.17",
Expand Down
17 changes: 16 additions & 1 deletion backend/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Module, MiddlewareConsumer, RequestMethod } from '@nestjs/common';
import { ThrottlerModule } from '@nestjs/throttler';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerGuard } from './auth/throttler.guard';
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerModule } from '@nestjs/throttler';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { HealthModule } from './health/health.module';
Expand All @@ -12,14 +12,29 @@ import { LoggingMiddleware } from './common/middleware/logging.middleware';
import { CreatorsModule } from './creators/creators.module';
import { SubscriptionsModule } from './subscriptions/subscriptions.module';
import { AuthModule } from './auth/auth.module';
import { AuditModule } from './audit/audit.module';
import { WebhookModule } from './webhook/webhook.module';
import { ExampleController } from './common/examples/example.controller';

@Module({
imports: [
ThrottlerModule.forRoot([{ name: 'auth', ttl: 60000, limit: 5 }]),
TypeOrmModule.forRoot({
type: 'postgres',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT ?? '5432', 10),
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
autoLoadEntities: true,
synchronize: process.env.TYPEORM_SYNC === 'true',
}),
LoggingModule,
AuditModule,
AuthModule,
CreatorsModule,
SubscriptionsModule,
WebhookModule,
HealthModule,
],
controllers: [AppController, ExampleController],
Expand Down
38 changes: 38 additions & 0 deletions backend/src/audit/audit-admin.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { ExecutionContext, ServiceUnavailableException, UnauthorizedException } from '@nestjs/common';
import { AuditAdminGuard } from './audit-admin.guard';

describe('AuditAdminGuard', () => {
const originalEnv = process.env.AUDIT_ADMIN_API_KEY;

afterEach(() => {
process.env.AUDIT_ADMIN_API_KEY = originalEnv;
});

function ctx(headers: Record<string, string>): ExecutionContext {
return {
switchToHttp: () => ({
getRequest: () => ({ headers }),
}),
} as ExecutionContext;
}

it('allows when key matches', () => {
process.env.AUDIT_ADMIN_API_KEY = 'secret-key';
const guard = new AuditAdminGuard();
expect(guard.canActivate(ctx({ 'x-admin-audit-key': 'secret-key' }))).toBe(true);
});

it('rejects when key mismatches', () => {
process.env.AUDIT_ADMIN_API_KEY = 'secret-key';
const guard = new AuditAdminGuard();
expect(() =>
guard.canActivate(ctx({ 'x-admin-audit-key': 'wrong' })),
).toThrow(UnauthorizedException);
});

it('503 when env not set', () => {
delete process.env.AUDIT_ADMIN_API_KEY;
const guard = new AuditAdminGuard();
expect(() => guard.canActivate(ctx({}))).toThrow(ServiceUnavailableException);
});
});
29 changes: 29 additions & 0 deletions backend/src/audit/audit-admin.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
CanActivate,
ExecutionContext,
Injectable,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { Request } from 'express';

/**
* Protects audit query endpoints. Set `AUDIT_ADMIN_API_KEY` in the environment.
*/
@Injectable()
export class AuditAdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const expected = process.env.AUDIT_ADMIN_API_KEY?.trim();
if (!expected) {
throw new ServiceUnavailableException(
'Audit query is disabled (AUDIT_ADMIN_API_KEY not configured)',
);
}
const req = context.switchToHttp().getRequest<Request>();
const provided = req.headers['x-admin-audit-key'];
if (typeof provided !== 'string' || provided !== expected) {
throw new UnauthorizedException('Invalid audit admin API key');
}
return true;
}
}
20 changes: 20 additions & 0 deletions backend/src/audit/audit-metadata.sanitizer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { sanitizeAuditMetadata } from './audit-metadata.sanitizer';

describe('sanitizeAuditMetadata', () => {
it('redacts sensitive keys', () => {
const out = sanitizeAuditMetadata({
user: 'ok',
password: 'super-secret',
nested: { apiKey: 'x', safe: 1 },
});
expect(out?.password).toBe('[REDACTED]');
expect(out?.nested).toEqual({ apiKey: '[REDACTED]', safe: 1 });
});

it('redacts JWT-shaped strings', () => {
const jwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U';
const out = sanitizeAuditMetadata({ token: jwt });
expect(out?.token).toBe('[REDACTED_JWT]');
});
});
41 changes: 41 additions & 0 deletions backend/src/audit/audit-metadata.sanitizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
const SENSITIVE_KEY = /password|secret|token|authorization|cookie|apikey|api[_-]?key|bearer|refresh|access|credential|newsecret|new_secret/i;

function looksLikeJwt(value: string): boolean {
const parts = value.split('.');
return parts.length === 3 && parts.every((p) => p.length > 10);
}

/**
* Strips or redacts values that must never appear in audit metadata (secrets, raw tokens).
*/
export function sanitizeAuditMetadata(
input: Record<string, unknown> | undefined | null,
): Record<string, unknown> | null {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
return null;
}
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(input)) {
if (SENSITIVE_KEY.test(k)) {
out[k] = '[REDACTED]';
continue;
}
if (typeof v === 'string') {
if (looksLikeJwt(v)) {
out[k] = '[REDACTED_JWT]';
} else {
out[k] = v;
}
continue;
}
if (v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Date)) {
const nested = sanitizeAuditMetadata(v as Record<string, unknown>);
if (nested && Object.keys(nested).length > 0) {
out[k] = nested;
}
} else {
out[k] = v as unknown;
}
}
return Object.keys(out).length ? out : null;
}
22 changes: 22 additions & 0 deletions backend/src/audit/audit.admin.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiHeader, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AuditAdminGuard } from './audit-admin.guard';
import { AuditService } from './audit.service';
import { AuditQueryDto } from './dto/audit-query.dto';
import { PaginatedResponseDto } from '../common/dto/paginated-response.dto';
import { AuditLog } from './entities/audit-log.entity';

@ApiTags('admin')
@ApiHeader({ name: 'x-admin-audit-key', required: true })
@Controller({ path: 'admin/audit', version: '1' })
@UseGuards(AuditAdminGuard)
export class AuditAdminController {
constructor(private readonly auditService: AuditService) {}

@Get()
@ApiOperation({ summary: 'Query audit log (requires AUDIT_ADMIN_API_KEY)' })
@ApiResponse({ status: 200, description: 'Paginated audit entries' })
query(@Query() query: AuditQueryDto): Promise<PaginatedResponseDto<AuditLog>> {
return this.auditService.query(query);
}
}
15 changes: 15 additions & 0 deletions backend/src/audit/audit.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditAdminController } from './audit.admin.controller';
import { AuditAdminGuard } from './audit-admin.guard';
import { AuditService } from './audit.service';
import { AuditLog } from './entities/audit-log.entity';
import { LoggingModule } from '../common/logging.module';

@Module({
imports: [TypeOrmModule.forFeature([AuditLog]), LoggingModule],
controllers: [AuditAdminController],
providers: [AuditService, AuditAdminGuard],
exports: [AuditService],
})
export class AuditModule {}
67 changes: 67 additions & 0 deletions backend/src/audit/audit.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RequestContextService } from '../common/services/request-context.service';
import { AuditableAction } from './auditable-action';
import { AuditService } from './audit.service';
import { AuditLog } from './entities/audit-log.entity';

describe('AuditService', () => {
let service: AuditService;
let repo: jest.Mocked<Pick<Repository<AuditLog>, 'create' | 'save' | 'findAndCount'>>;

beforeEach(async () => {
repo = {
create: jest.fn((x) => x as AuditLog),
save: jest.fn().mockResolvedValue({}),
findAndCount: jest.fn().mockResolvedValue([[], 0]),
} as unknown as jest.Mocked<Pick<Repository<AuditLog>, 'create' | 'save' | 'findAndCount'>>;

const module: TestingModule = await Test.createTestingModule({
providers: [
AuditService,
{
provide: getRepositoryToken(AuditLog),
useValue: repo,
},
{
provide: RequestContextService,
useValue: { getCorrelationId: jest.fn().mockReturnValue('corr-1') },
},
],
}).compile();

service = module.get(AuditService);
});

it('persists sanitized metadata', async () => {
await service.record({
action: AuditableAction.AUTH_SESSION_CREATED,
actorType: 'user',
actorId: 'GADDR',
metadata: { password: 'secret', ok: true },
});
const saved = repo.save.mock.calls[0][0] as AuditLog;
expect(saved.metadata?.password).toBe('[REDACTED]');
expect(saved.metadata?.ok).toBe(true);
});

it('does not throw when persistence fails', async () => {
repo.save.mockRejectedValueOnce(new Error('db down'));
await expect(
service.record({
action: AuditableAction.AUTH_SESSION_CREATED,
actorType: 'user',
actorId: 'GADDR',
}),
).resolves.toBeUndefined();
});

it('query returns paginated rows', async () => {
const row = { id: '1' } as AuditLog;
repo.findAndCount.mockResolvedValue([[row], 1]);
const result = await service.query({ page: 1, limit: 10 });
expect(result.data).toEqual([row]);
expect(result.total).toBe(1);
});
});
Loading