🩹 fix(auth): fix gift code route prefix and JWT issuer validation

- Remove duplicate route prefix in GiftsController (was /api/v1/api/v1/gifts)
- Fix JwtAuthGuard to use JWT_ISSUER as fallback when BASE_URL is not set
- Add comprehensive GIFT_CODES.md documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-13 23:54:36 +01:00
parent 0485ce4b07
commit 284cd004aa
3 changed files with 263 additions and 9 deletions

View file

@ -0,0 +1,257 @@
# Gift Code System
User-generated gift codes for sharing credits across the Mana ecosystem.
## Overview
Users can create gift codes to share credits with others. The system supports various modes:
| Type | Description |
|------|-------------|
| `simple` | Single-use, one recipient |
| `personalized` | Restricted to specific email/Matrix ID |
| `split` | Divided into portions (e.g., 100 credits / 5 = 20 each) |
| `first_come` | First N users get full amount |
| `riddle` | Requires correct answer to redeem |
## API Endpoints
Base URL: `/api/v1/gifts`
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| GET | `/:code` | - | Get gift code info (public preview) |
| POST | `/` | JWT | Create new gift code |
| POST | `/:code/redeem` | JWT | Redeem a gift code |
| GET | `/me/created` | JWT | List codes you created |
| GET | `/me/received` | JWT | List gifts you received |
| DELETE | `/:id` | JWT | Cancel code & refund unclaimed |
## Usage Examples
### Create Gift Code
```bash
curl -X POST "https://auth.mana.how/api/v1/gifts" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"credits": 100,
"message": "Happy Birthday!",
"type": "simple"
}'
```
**Response:**
```json
{
"id": "uuid",
"code": "ABC123",
"url": "https://mana.how/g/ABC123",
"totalCredits": 100,
"creditsPerPortion": 100,
"totalPortions": 1,
"type": "simple",
"expiresAt": "2026-05-14T00:00:00Z"
}
```
### Create Split Gift (5 portions)
```bash
curl -X POST "https://auth.mana.how/api/v1/gifts" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"credits": 100,
"type": "split",
"portions": 5,
"message": "Share this with friends!"
}'
```
Each recipient gets 20 credits (100 / 5).
### Create Riddle Gift
```bash
curl -X POST "https://auth.mana.how/api/v1/gifts" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"credits": 50,
"type": "riddle",
"riddleQuestion": "What is the capital of Germany?",
"riddleAnswer": "Berlin"
}'
```
### Get Gift Info (Public)
```bash
curl "https://auth.mana.how/api/v1/gifts/ABC123"
```
**Response:**
```json
{
"code": "ABC123",
"type": "simple",
"status": "active",
"creditsPerPortion": 100,
"totalPortions": 1,
"remainingPortions": 1,
"message": "Happy Birthday!",
"hasRiddle": false,
"isPersonalized": false,
"expiresAt": "2026-05-14T00:00:00Z",
"creatorName": "John Doe"
}
```
### Redeem Gift Code
```bash
curl -X POST "https://auth.mana.how/api/v1/gifts/ABC123/redeem" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
```
For riddle gifts, include the answer:
```bash
curl -X POST "https://auth.mana.how/api/v1/gifts/ABC123/redeem" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"answer": "Berlin"}'
```
**Response:**
```json
{
"success": true,
"creditsReceived": 100,
"newBalance": 250,
"message": "Happy Birthday!"
}
```
## Matrix Bot Commands
### German
```
!geschenk 50 # Simple gift
!geschenk 100 /5 # Split into 5 portions
!geschenk 50 ?="Berlin" # With riddle
!geschenk 50 "Viel Spass!" # With message
!einloesen ABC123 # Redeem code
!einloesen ABC123 Berlin # Redeem with riddle answer
!meine-geschenke # List your gifts
```
### English
```
!gift 50
!gift 100 /5
!gift 50 ?="Berlin"
!redeem ABC123
!my-gifts
```
## Database Schema
### Tables
**gifts.gift_codes**
- `id` - UUID primary key
- `code` - Unique 6-char code (e.g., "ABC123")
- `short_url` - Full URL (e.g., "mana.how/g/ABC123")
- `creator_id` - FK to auth.users
- `total_credits` - Reserved amount
- `credits_per_portion` - Credits per redemption
- `total_portions` - Number of portions
- `claimed_portions` - Portions already redeemed
- `type` - simple|personalized|split|first_come|riddle
- `status` - active|depleted|expired|cancelled|refunded
- `target_email` - For personalized gifts
- `target_matrix_id` - For personalized gifts
- `riddle_question` - Question text
- `riddle_answer_hash` - bcrypt hash of answer
- `message` - Optional message
- `expires_at` - Expiration timestamp
- `reservation_transaction_id` - FK to credits.transactions
**gifts.gift_redemptions**
- `id` - UUID primary key
- `gift_code_id` - FK to gift_codes
- `redeemer_user_id` - FK to auth.users
- `status` - success|failed_wrong_answer|failed_wrong_user|...
- `credits_received` - Amount credited
- `portion_number` - Which portion was claimed
- `credit_transaction_id` - FK to credits.transactions
- `source_app_id` - 'matrix-bot', 'web', etc.
### Transaction Types
Credits schema includes gift-related transaction types:
- `gift_reserve` - Credits reserved when creating gift
- `gift_release` - Credits returned when cancelling gift
- `gift_receive` - Credits received when redeeming gift
## Integration Points
### Web Apps (SvelteKit)
```typescript
// Fetch gift info
const response = await fetch(`/api/v1/gifts/${code}`);
const giftInfo = await response.json();
// Redeem
const result = await fetch(`/api/v1/gifts/${code}/redeem`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ answer: riddleAnswer })
});
```
### Matrix Bots
```typescript
import { GiftService } from '@manacore/bot-services';
import { handleGiftCommand } from '@manacore/matrix-bot-common';
// In bot command handler
if (isGiftCommand(command)) {
return handleGiftCommand(this, roomId, userId, command, args);
}
```
### Mobile Apps (Expo)
Same REST API, use fetch or axios with JWT token.
## Security
- Gift codes use 6-char alphanumeric codes (no ambiguous chars)
- Riddle answers are bcrypt hashed
- Row-level locking prevents race conditions
- Credits are reserved atomically when creating gifts
- Personalized gifts verify email or Matrix ID
## Configuration
Environment variables:
```env
# Base URL for short links
APP_BASE_URL=https://mana.how
```
Gift code rules (hardcoded):
```typescript
const GIFT_CODE_RULES = {
minCredits: 1,
maxCredits: 10000,
maxPortions: 100,
maxMessageLength: 500,
maxRiddleQuestionLength: 200,
defaultExpirationDays: 90,
};
```

View file

@ -43,9 +43,10 @@ export class JwtAuthGuard implements CanActivate {
}
// IMPORTANT: Match Better Auth signing config exactly (better-auth.config.ts)
// Signing uses: issuer = BASE_URL, audience = JWT_AUDIENCE || 'manacore'
const baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3001';
const issuer = baseUrl; // Better Auth uses BASE_URL as issuer for OIDC compatibility
// Better Auth uses: issuer = BASE_URL || JWT_ISSUER || 'http://localhost:3001'
const baseUrl = this.configService.get<string>('BASE_URL');
const jwtIssuer = this.configService.get<string>('jwt.issuer');
const issuer = baseUrl || jwtIssuer || 'http://localhost:3001';
const audience = this.configService.get<string>('jwt.audience') || 'manacore';
const { payload } = await jwtVerify(token, this.jwks, {

View file

@ -16,7 +16,7 @@ import { CreateGiftDto } from './dto/create-gift.dto';
import { RedeemGiftDto } from './dto/redeem-gift.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
@Controller('api/v1/gifts')
@Controller('gifts')
export class GiftsController {
constructor(private readonly giftCodeService: GiftCodeService) {}
@ -50,11 +50,7 @@ export class GiftsController {
@Post(':code/redeem')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
async redeemGift(
@Request() req: any,
@Param('code') code: string,
@Body() dto: RedeemGiftDto
) {
async redeemGift(@Request() req: any, @Param('code') code: string, @Body() dto: RedeemGiftDto) {
const userId = req.user.sub;
const userEmail = req.user.email;
// Matrix ID would be passed in the request if coming from a Matrix bot