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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ RATE_LIMIT_STORE=memory
# (shared across instances). `redis` needs `npm install ioredis` and REDIS_URL.
# See docs/CACHING.md.
CACHE_DRIVER=memory
# CACHE_TTL_SECONDS=60

# Outbound webhook delivery queue (BullMQ). Disabled by default. Enabling it
# needs `npm install bullmq` and REDIS_URL (Redis with maxmemory-policy
Expand All @@ -75,3 +76,11 @@ IDEMPOTENCY_KEY_TTL_MINUTES=15
# Experimental features. Flags are disabled unless explicitly set to true.
FEATURE_FEE_BUMP=false
FEATURE_INDEXER=false

# JSON body size limit (e.g. 100kb, 1mb). Rejects payloads exceeding this with 413.
JSON_BODY_LIMIT=100kb

# CSP directives as JSON object string for helmet contentSecurityPolicy.
# Leave unset to use helmet defaults. Example:
# CSP_DIRECTIVES={"defaultSrc":["'self'"],"scriptSrc":["'self'"]}
# CSP_DIRECTIVES=
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@ COPY . .
RUN npx prisma generate && npm run build

FROM node:22-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/prisma ./prisma
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/',(r)=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"
CMD ["node", "dist/main"]
21 changes: 21 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,24 @@ or malformed. A handful of variables are read directly by `ConfigService`
without going through validation — they are flagged **no** above. A typo in
one of those fails *silently* and the feature simply stays on its default, so
check the spelling if a flag or limit does not take effect.

## Security Headers (Helmet)

The API uses [helmet](https://helmetjs.github.io/) with these defaults:
- `contentSecurityPolicy`: enabled (configurable via `CSP_DIRECTIVES`)
- `crossOriginEmbedderPolicy`: enabled
- `crossOriginOpenerPolicy`: enabled
- `crossOriginResourcePolicy`: enabled
- `dnsPrefetchControl`: enabled
- `frameguard`: enabled (deny)
- `hidePoweredBy`: enabled
- `hsts`: enabled (1 year, includeSubDomains)
- `ieNoOpen`: enabled
- `noSniff`: enabled
- `originAgentCluster`: enabled
- `referrerPolicy`: enabled (no-when-downgrade)
- `xssFilter`: enabled

Disabled headers (not needed for API-only backend):
- `x-powered-by`: removed by `hidePoweredBy`
- `x-dns-prefetch-control`: controlled by `dnsPrefetchControl`
56 changes: 56 additions & 0 deletions src/common/interceptors/response-cache.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { CACHE_STORE } from '../cache/cache-store';
import { Inject } from '@nestjs/common';
import type { Request } from 'express';

interface CacheStore {
get<T>(k: string): Promise<T | undefined>;
set(k: string, v: unknown, ttl: number): Promise<void>;
delete(k: string): Promise<void>;
}

@Injectable()
export class ResponseCacheInterceptor implements NestInterceptor {
private readonly defaultTtlMs: number;

constructor(
@Inject(CACHE_STORE) private readonly cache: CacheStore,
private readonly config: ConfigService,
) {
this.defaultTtlMs = config.get<number>('CACHE_TTL_SECONDS', 60) * 1000;
}

async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<unknown>> {
const req = context.switchToHttp().getRequest<Request>();
if (req.method !== 'GET') {
return next.handle();
}
const key = `cache:${req.url}`;
const cached = await this.cache.get<unknown>(key);
if (cached !== undefined) {
return of(cached);
}
return next.handle().pipe(
tap({
next: (data: unknown) => {
void this.cache.set(key, data, this.defaultTtlMs);
},
}),
);
}

invalidate(pattern: string): Promise<void> {
return this.cache.delete(pattern);
}
}
4 changes: 4 additions & 0 deletions src/events/events.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Post,
Query,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
Expand All @@ -17,18 +18,21 @@ import { CreateEventDto } from './dto/create-event.dto';
import { CreateTicketTypeDto } from './dto/create-ticket-type.dto';
import { ConfirmPublishDto } from './dto/confirm-publish.dto';
import { ListOrganizationEventsQueryDto } from './dto/list-organization-events-query.dto';
import { ResponseCacheInterceptor } from '../common/interceptors/response-cache.interceptor';

@Controller()
export class EventsController {
constructor(private readonly eventsService: EventsService) {}

@Get('events')
@Header('Cache-Control', 'public, max-age=60, s-maxage=300')
@UseInterceptors(ResponseCacheInterceptor)
findPublished() {
return this.eventsService.findPublished();
}

@Get('events/:eventId')
@UseInterceptors(ResponseCacheInterceptor)
findOne(@Param('eventId') eventId: string) {
return this.eventsService.getWithOrg(eventId);
}
Expand Down
3 changes: 2 additions & 1 deletion src/events/events.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { OrganizationsModule } from '../organizations/organizations.module';
import { StellarModule } from '../stellar/stellar.module';
import { AuditModule } from '../audit/audit.module';
import { CacheModule } from '../common/cache/cache.module';
import { EventsController } from './events.controller';
import { EventsService } from './events.service';
import { EventReminderService } from './event-reminder.service';

@Module({
imports: [OrganizationsModule, StellarModule, AuditModule],
imports: [OrganizationsModule, StellarModule, AuditModule, CacheModule],
controllers: [EventsController],
providers: [EventsService, EventReminderService],
exports: [EventsService, EventReminderService],
Expand Down
75 changes: 60 additions & 15 deletions src/events/events.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe('EventsService', () => {
submitSignedTransaction: jest.Mock;
getEvent: jest.Mock;
};
let cache: { delete: jest.Mock };

beforeEach(() => {
prisma = {
Expand All @@ -53,11 +54,14 @@ describe('EventsService', () => {
organizer: 'GORG',
}),
};
cache = { delete: jest.fn().mockResolvedValue(undefined) };

service = new EventsService(
prisma as unknown as PrismaService,
organizations as unknown as OrganizationsService,
stellar as unknown as StellarService,
undefined,
cache,
);
});

Expand All @@ -71,7 +75,7 @@ describe('EventsService', () => {
chainEventId: null,
}),
organization: createOrganization({ stellarAccount: 'GORG' }),
} as never);
});

await expect(
service.buildPublishTx('organizer-1', 'event-1'),
Expand All @@ -88,7 +92,7 @@ describe('EventsService', () => {
chainEventId: 99n,
}),
organization: createOrganization({ stellarAccount: 'GORG' }),
} as never);
});

await expect(
service.buildPublishTx('organizer-1', 'event-1'),
Expand All @@ -104,7 +108,7 @@ describe('EventsService', () => {
chainEventId: null,
}),
organization: createOrganization({ stellarAccount: 'GORG' }),
} as never);
});
prisma.ticketType.count.mockResolvedValue(0);

const attempt = service.buildPublishTx('organizer-1', 'event-1');
Expand All @@ -131,7 +135,7 @@ describe('EventsService', () => {
royaltyBps: 500,
}),
organization: createOrganization({ stellarAccount: 'GORG' }),
} as never);
});
prisma.event.update.mockResolvedValue({});

const { unsignedXdr } = await service.buildPublishTx(
Expand Down Expand Up @@ -165,7 +169,7 @@ describe('EventsService', () => {
chainEventId: null,
}),
organization: createOrganization({ stellarAccount: 'GORG' }),
} as never);
});

await expect(
service.confirmPublish('organizer-1', 'event-1', 'signed-xdr'),
Expand All @@ -180,7 +184,7 @@ describe('EventsService', () => {
chainEventId: 99n,
}),
organization: createOrganization({ stellarAccount: 'GORG' }),
} as never);
});
stellar.getEvent.mockResolvedValue({
eventId: 100n,
organizer: 'GOTHER',
Expand All @@ -200,7 +204,7 @@ describe('EventsService', () => {
chainEventId: 99n,
}),
organization: createOrganization({ stellarAccount: 'GORG' }),
} as never);
});
prisma.event.update.mockResolvedValue(
createEvent({ id: 'event-1', status: 'PUBLISHED' }),
);
Expand Down Expand Up @@ -232,7 +236,7 @@ describe('EventsService', () => {
status: 'PUBLISHED',
}),
organization: createOrganization({ stellarAccount: 'GORG' }),
} as never);
});
prisma.ticket.count.mockResolvedValue(0);
prisma.event.update.mockResolvedValue(
createEvent({ id: 'event-1', status: 'DRAFT' }),
Expand Down Expand Up @@ -312,7 +316,11 @@ describe('EventsService', () => {

expect(prisma.event.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { organizationId: 'org-1', deletedAt: null, status: 'PUBLISHED' },
where: {
organizationId: 'org-1',
deletedAt: null,
status: 'PUBLISHED',
},
}),
);
});
Expand Down Expand Up @@ -367,12 +375,12 @@ describe('EventsService', () => {

expect(prisma.event.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ deletedAt: null }),
where: expect.objectContaining({ deletedAt: null }) as never,
}),
);
expect(prisma.event.count).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ deletedAt: null }),
where: expect.objectContaining({ deletedAt: null }) as never,
}),
);
});
Expand All @@ -384,7 +392,7 @@ describe('EventsService', () => {
...createEvent({ id: 'event-1', organizationId: 'org-1' }),
deletedAt: new Date('2026-09-26T00:00:00.000Z'),
organization: createOrganization({ stellarAccount: 'GORG' }),
} as never);
});

await expect(service.getWithOrg('event-1')).rejects.toMatchObject({
status: 404,
Expand All @@ -396,14 +404,14 @@ describe('EventsService', () => {
...createEvent({ id: 'event-1', organizationId: 'org-1' }),
deletedAt: null,
organization: createOrganization({ stellarAccount: 'GORG' }),
} as never);
});
prisma.event.update.mockResolvedValue({ id: 'event-1' });

await service.softDelete('organizer-1', 'event-1');

expect(prisma.event.update).toHaveBeenCalledWith({
where: { id: 'event-1' },
data: { deletedAt: expect.any(Date) },
data: { deletedAt: expect.any(Date) as never },
});
});

Expand All @@ -412,7 +420,7 @@ describe('EventsService', () => {
...createEvent({ id: 'event-1', organizationId: 'org-1' }),
deletedAt: new Date('2026-09-26T00:00:00.000Z'),
organization: createOrganization({ stellarAccount: 'GORG' }),
} as never);
});
prisma.event.update.mockResolvedValue({ id: 'event-1' });

await service.restore('organizer-1', 'event-1');
Expand All @@ -423,4 +431,41 @@ describe('EventsService', () => {
});
});
});

describe('cache invalidation', () => {
it('invalidates cache on create', async () => {
prisma.event.create.mockResolvedValue({ id: 'event-1' });
await service.create('user-1', 'org-1', {
name: 'Test',
category: 'CONCERTS',
startsAt: new Date(),
} as never);
expect(cache.delete).toHaveBeenCalledWith('cache:/v1/events');
});

it('invalidates cache on confirmPublish', async () => {
prisma.event.findUnique.mockResolvedValue({
...createEvent({
id: 'event-1',
organizationId: 'org-1',
chainEventId: 99n,
}),
organization: createOrganization({ stellarAccount: 'GORG' }),
});
prisma.event.update.mockResolvedValue({ id: 'event-1' });
await service.confirmPublish('organizer-1', 'event-1', 'signed-xdr');
expect(cache.delete).toHaveBeenCalledWith('cache:/v1/events');
});

it('invalidates cache on softDelete', async () => {
prisma.event.findUnique.mockResolvedValue({
...createEvent({ id: 'event-1', organizationId: 'org-1' }),
deletedAt: null,
organization: createOrganization({ stellarAccount: 'GORG' }),
});
prisma.event.update.mockResolvedValue({ id: 'event-1' });
await service.softDelete('organizer-1', 'event-1');
expect(cache.delete).toHaveBeenCalledWith('cache:/v1/events');
});
});
});
Loading