-
Notifications
You must be signed in to change notification settings - Fork 55
idempotency key support implemented #282
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import { MigrationInterface, QueryRunner, TableColumn, TableIndex } from 'typeorm'; | ||
|
|
||
| export class AddIdempotencyKeyToTips1769950000000 implements MigrationInterface { | ||
| public async up(queryRunner: QueryRunner): Promise<void> { | ||
| await queryRunner.addColumn( | ||
| 'tips', | ||
| new TableColumn({ | ||
| name: 'idempotencyKey', | ||
| type: 'varchar', | ||
| length: '128', | ||
| isNullable: true, | ||
| isUnique: true, | ||
| }), | ||
| ); | ||
|
|
||
| await queryRunner.createIndex( | ||
| 'tips', | ||
| new TableIndex({ | ||
| name: 'IDX_tips_idempotencyKey', | ||
| columnNames: ['idempotencyKey'], | ||
| isUnique: true, | ||
| }), | ||
| ); | ||
| } | ||
|
|
||
| public async down(queryRunner: QueryRunner): Promise<void> { | ||
| await queryRunner.dropIndex('tips', 'IDX_tips_idempotencyKey'); | ||
| await queryRunner.dropColumn('tips', 'idempotencyKey'); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -65,7 +65,20 @@ export class TipsService { | |
| ) {} | ||
|
|
||
| async create(userId: string, createTipDto: CreateTipDto): Promise<Tip> { | ||
| const { artistId, trackId, stellarTxHash, message } = createTipDto; | ||
| const { artistId, trackId, stellarTxHash, message, idempotencyKey } = createTipDto; | ||
|
Comment on lines
67
to
+68
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Check if fromUser is set anywhere during tip creation
ast-grep --pattern 'fromUser: $_'Repository: OlufunbiIK/tip-tune Length of output: 45 🏁 Script executed: fd -e "\.ts$" tips | grep -E "(entity|service)" | head -20Repository: OlufunbiIK/tip-tune Length of output: 45 🏁 Script executed: cat -n backend/src/tips/tips.entity.ts | head -100Repository: OlufunbiIK/tip-tune Length of output: 126 🏁 Script executed: cat -n backend/src/tips/tips.service.ts | sed -n '60,90p'Repository: OlufunbiIK/tip-tune Length of output: 1363 🏁 Script executed: cat -n backend/src/tips/tips.service.ts | sed -n '160,185p'Repository: OlufunbiIK/tip-tune Length of output: 985 🏁 Script executed: find . -type f -name "*entity*" | grep -i tipRepository: OlufunbiIK/tip-tune Length of output: 152 🏁 Script executed: find . -type f -name "*.ts" | xargs grep -l "fromUser" | head -10Repository: OlufunbiIK/tip-tune Length of output: 426 🏁 Script executed: rg "class Tip" -A 30Repository: OlufunbiIK/tip-tune Length of output: 21374 🏁 Script executed: cat -n backend/src/tips/entities/tip.entity.tsRepository: OlufunbiIK/tip-tune Length of output: 4776 Set The Tip entity has a required 🤖 Prompt for AI Agents |
||
|
|
||
| // --- Idempotency key check: replay the original response if key already seen --- | ||
| if (idempotencyKey) { | ||
| const existing = await this.tipRepository.findOne({ | ||
| where: { idempotencyKey }, | ||
| }); | ||
| if (existing) { | ||
| this.logger.log( | ||
| `Idempotency replay for key=${idempotencyKey}, tipId=${existing.id}`, | ||
| ); | ||
| return existing; | ||
| } | ||
|
Comment on lines
+70
to
+80
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Security: Idempotency replay leaks tip data across users. The lookup queries only by Either scope the idempotency key per-user (requires schema change to a compound unique constraint on Minimal fix: Validate ownership before replay if (idempotencyKey) {
const existing = await this.tipRepository.findOne({
where: { idempotencyKey },
});
if (existing) {
+ // Verify the cached tip belongs to the requesting user
+ if (existing.fromUser !== userId) {
+ throw new ConflictException(
+ 'Idempotency key is already in use by another request',
+ );
+ }
this.logger.log(
`Idempotency replay for key=${idempotencyKey}, tipId=${existing.id}`,
);
return existing;
}
}🤖 Prompt for AI Agents |
||
| } | ||
|
Comment on lines
+71
to
+81
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Race condition: Concurrent requests with the same new key cause unhandled exception. Two concurrent requests with the same (new) Wrap the save in a try-catch to handle the duplicate key error gracefully. Suggested fix: Catch unique constraint violation and retry lookup- const savedTip = await this.tipRepository.save(newTip);
+ let savedTip: Tip;
+ try {
+ savedTip = await this.tipRepository.save(newTip);
+ } catch (error: any) {
+ // Handle race condition: another request inserted the same idempotencyKey
+ if (
+ idempotencyKey &&
+ error.code === '23505' // PostgreSQL unique violation
+ ) {
+ const existing = await this.tipRepository.findOne({
+ where: { idempotencyKey },
+ });
+ if (existing && existing.fromUser === userId) {
+ this.logger.log(
+ `Idempotency replay (race recovery) for key=${idempotencyKey}, tipId=${existing.id}`,
+ );
+ return existing;
+ }
+ throw new ConflictException('Idempotency key collision');
+ }
+ throw error;
+ }Also applies to: 182-185 🤖 Prompt for AI Agents |
||
|
|
||
| const existingTip = await this.tipRepository.findOne({ | ||
| where: { stellarTxHash }, | ||
|
|
@@ -166,6 +179,7 @@ export class TipsService { | |
| status: TipStatus.VERIFIED, | ||
| verifiedAt: new Date(), | ||
| stellarTimestamp: new Date(txDetails.created_at), | ||
| ...(idempotencyKey ? { idempotencyKey } : {}), | ||
| }); | ||
|
|
||
| const savedTip = await this.tipRepository.save(newTip); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Header value bypasses DTO validation.
The
idempotencyKeyHeaderis assigned tocreateTipDto.idempotencyKeyafter the DTO has already been validated by theValidationPipe. This means the header value bypasses@MaxLength(128)validation—a header longer than 128 characters will pass through and could cause a database error or truncation.Suggested fix: Add explicit validation
// Header takes precedence over body field; merge into DTO if (idempotencyKeyHeader) { + if (idempotencyKeyHeader.length > 128) { + throw new BadRequestException('Idempotency-Key header must not exceed 128 characters'); + } createTipDto.idempotencyKey = idempotencyKeyHeader; }📝 Committable suggestion
🤖 Prompt for AI Agents