Skip to content

Commit 0082a3d

Browse files
committed
fix(api): require scraper API key for POST /ai/process
Batch summarization+embedding is an ops job, not an end-user feature. Any signed-in JWT could previously trigger OpenAI spend. Protect the route with ScraperKeyGuard (SCRAPER_API_KEY), same as POST /scraper/run. Update Bruno docs and add e2e coverage that rejects missing auth, user JWTs, and wrong keys.
1 parent 44bdbcf commit 0082a3d

5 files changed

Lines changed: 82 additions & 8 deletions

File tree

apps/api/src/ai/ai.controller.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import { Controller, Post, UseGuards } from '@nestjs/common';
22
import { AiService } from './ai.service';
3-
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
3+
import { ScraperKeyGuard } from '../scraper/scraper-key.guard';
44

55
@Controller('ai')
66
export class AiController {
77
constructor(private readonly aiService: AiService) {}
88

9-
@UseGuards(JwtAuthGuard)
9+
/**
10+
* Ops-only batch job: summarize + embed articles that still lack a summary.
11+
* Protected with SCRAPER_API_KEY (same trust model as POST /scraper/run) so
12+
* any signed-in end user cannot burn OpenAI quota by calling this repeatedly.
13+
* Normal summarization also runs via cron and POST /scraper/run.
14+
*/
15+
@UseGuards(ScraperKeyGuard)
1016
@Post('process')
1117
async process() {
1218
const result = await this.aiService.processUnsummarized();

apps/api/src/ai/ai.module.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
import { Module, forwardRef } from '@nestjs/common';
1+
import { Module } from '@nestjs/common';
22
import { AiService } from './ai.service';
33
import { AiController } from './ai.controller';
4-
import { AuthModule } from '../auth/auth.module';
4+
import { ScraperKeyGuard } from '../scraper/scraper-key.guard';
55

66
@Module({
7-
imports: [forwardRef(() => AuthModule)],
8-
providers: [AiService],
7+
providers: [AiService, ScraperKeyGuard],
98
controllers: [AiController],
109
exports: [AiService],
1110
})

apps/api/test/app.e2e-spec.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { AppService } from '../src/app.service';
88

99
import { ScraperController } from '../src/scraper/scraper.controller';
1010
import { ScraperService, ScrapeResult } from '../src/scraper/scraper.service';
11+
import { AiController } from '../src/ai/ai.controller';
1112
import { AiService } from '../src/ai/ai.service';
1213
import { FeedController } from '../src/feed/feed.controller';
1314
import { FeedService, FeedResponse } from '../src/feed/feed.service';
@@ -131,6 +132,68 @@ describe('API E2E (isolated modules)', () => {
131132
});
132133
});
133134

135+
describe('Ops-only AI process (AiController + ScraperKeyGuard)', () => {
136+
let aiService: jest.Mocked<AiService>;
137+
138+
beforeEach(async () => {
139+
aiService = {
140+
processUnsummarized: jest.fn(),
141+
} as any;
142+
143+
const moduleFixture: TestingModule = await Test.createTestingModule({
144+
controllers: [AiController],
145+
providers: [{ provide: AiService, useValue: aiService }],
146+
}).compile();
147+
148+
app = moduleFixture.createNestApplication();
149+
app.useGlobalPipes(
150+
new ValidationPipe({ whitelist: true, transform: true }),
151+
);
152+
await app.init();
153+
});
154+
155+
afterEach(async () => {
156+
await app.close();
157+
});
158+
159+
it('POST /ai/process requires SCRAPER_API_KEY (rejects missing, JWT, wrong key)', async () => {
160+
const origKey = process.env.SCRAPER_API_KEY;
161+
process.env.SCRAPER_API_KEY = 'test-scraper-key-xyz';
162+
163+
(aiService.processUnsummarized as jest.Mock).mockResolvedValue({
164+
processed: 3,
165+
failed: 0,
166+
});
167+
168+
// no auth
169+
await request(app.getHttpServer()).post('/ai/process').expect(401);
170+
171+
// user JWT must not work (this is ops-only, not end-user)
172+
const { authHeader } = await getTestAuthContextForE2E();
173+
await request(app.getHttpServer())
174+
.post('/ai/process')
175+
.set('Authorization', authHeader)
176+
.expect(401);
177+
178+
// wrong scraper key
179+
await request(app.getHttpServer())
180+
.post('/ai/process')
181+
.set('Authorization', 'Bearer wrong-key')
182+
.expect(401);
183+
184+
// valid scraper key
185+
await request(app.getHttpServer())
186+
.post('/ai/process')
187+
.set('Authorization', 'Bearer test-scraper-key-xyz')
188+
.expect(201)
189+
.expect({ processed: 3, failed: 0 });
190+
191+
expect(aiService.processUnsummarized).toHaveBeenCalledTimes(1);
192+
193+
process.env.SCRAPER_API_KEY = origKey;
194+
});
195+
});
196+
134197
describe('Protected feed (JwtAuthGuard + FeedController)', () => {
135198
let feedService: jest.Mocked<FeedService>;
136199

bruno/Process Summaries.bru

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,8 @@ post {
99
body: none
1010
auth: none
1111
}
12+
13+
headers {
14+
Authorization: Bearer {{scraperKey}}
15+
}
16+

bruno/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,16 +97,17 @@ Constraints: `message` max 500 chars, `history` max 20 turns, each `content` max
9797

9898
### Process Summaries
9999
Manually trigger summarization + embedding for articles that have content but no summary yet.
100+
Ops-only — same key as the scraper (not a user JWT).
100101

101102
```
102103
POST /ai/process
103-
Authorization: Bearer {{authToken}}
104+
Authorization: Bearer {{scraperKey}}
104105
```
105106

106107
---
107108

108109
## Notes
109110

110-
- The scraper endpoint requires `SCRAPER_API_KEY` (not a user JWT). Set it as `scraperKey` in your Bruno environment.
111+
- The scraper and AI process endpoints require `SCRAPER_API_KEY` (not a user JWT). Set it as `scraperKey` in your Bruno environment.
111112
- Chat history `role` must be `"user"` or `"assistant"``"system"` is rejected at the API boundary.
112113
- Render's free tier sleeps after inactivity; the first request after a cold start may take ~30s.

0 commit comments

Comments
 (0)