feat(yucca-admin-api): manage and cancel discord beta-invite drops via yuctl (#575)

This commit is contained in:
Antoine Lecompte
2026-08-26 20:15:05 +01:00
committed by GitHub
parent cca37e0200
commit b726229b14
36 changed files with 1235 additions and 10 deletions
@@ -2,6 +2,7 @@ import { LoggerRepository, LoggingInterceptor, OtelModule, WideContextRepository
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { InternalController } from './controllers/internal.controller';
import { DiscordRepository } from './repositories/discord.repository';
import { TranscriptStorageRepository } from './repositories/transcriptStorage.repository';
import { YuccaApiRepository } from './repositories/yuccaApi.repository';
@@ -25,6 +26,7 @@ export const providers = [
@Module({
imports: [OtelModule, ...imports],
controllers: [InternalController],
providers,
})
export class AppModule {}
@@ -0,0 +1,26 @@
import { BadRequestException, Body, Controller, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common';
import { InternalGuard } from 'src/middleware/internal.guard';
import { InviteService } from 'src/services/invite.service';
import { z } from 'zod';
const closeDropSchema = z.object({
batchId: z.string().min(1),
channelId: z.string().min(1),
messageId: z.string().min(1),
});
@Controller('/internal/drops')
@UseGuards(InternalGuard)
export class InternalController {
constructor(private readonly invite: InviteService) {}
@Post('/close')
@HttpCode(HttpStatus.NO_CONTENT)
async closeDrop(@Body() body: unknown): Promise<void> {
const parsed = closeDropSchema.safeParse(body);
if (!parsed.success) {
throw new BadRequestException(parsed.error.message);
}
await this.invite.closeDrop(parsed.data.batchId, parsed.data.channelId, parsed.data.messageId);
}
}
@@ -63,4 +63,6 @@ export const Messages = {
claimAlreadyLinked: 'You already have a FUTO Backups account, no invite needed.',
claimInviteUsed: 'You already used your beta invite.',
claimExhausted: 'All invites have been claimed, keep an eye out for the next drop.',
claimDropEnded: 'This drop has ended, keep an eye out for the next one.',
inviteDropEndedButton: 'Drop ended',
};
@@ -0,0 +1,23 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { createHash, timingSafeEqual } from 'node:crypto';
import { env } from 'src/env';
export const INTERNAL_SECRET_HEADER = 'x-internal-secret';
const digest = (value: string) => createHash('sha256').update(value).digest();
@Injectable()
export class InternalGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<{ headers: Record<string, string | string[] | undefined> }>();
const secret = request.headers[INTERNAL_SECRET_HEADER];
if (
!env.INTERNAL_SECRET ||
typeof secret !== 'string' ||
!timingSafeEqual(digest(secret), digest(env.INTERNAL_SECRET))
) {
throw new UnauthorizedException();
}
return true;
}
}
@@ -10,6 +10,7 @@ import {
Interaction,
Message,
MessageCreateOptions,
MessageEditOptions,
SlashCommandBuilder,
TextChannel,
ThreadChannel,
@@ -96,6 +97,12 @@ export class DiscordRepository {
return channel.send(message);
}
async editMessage(channelId: string, messageId: string, edit: MessageEditOptions): Promise<void> {
const channel = await this.textChannel(channelId);
const message = await channel.messages.fetch(messageId);
await message.edit(edit);
}
async sendDirectMessage(discordUserId: string, message: MessageCreateOptions): Promise<boolean> {
const user = await this.requireClient().users.fetch(discordUserId);
try {
@@ -42,7 +42,7 @@ export type UserSummary = z.infer<typeof userSummarySchema>;
export type LinkRequestCreated = z.infer<typeof linkRequestCreatedSchema>;
export type InviteResult =
| ({ status: 'ok' } & z.infer<typeof inviteCreatedSchema>)
| { status: 'already-linked' | 'invite-used' | 'exhausted' };
| { status: 'already-linked' | 'invite-used' | 'exhausted' | 'cancelled' };
@Injectable()
export class YuccaApiRepository {
@@ -116,6 +116,9 @@ export class YuccaApiRepository {
case 'BATCH_EXHAUSTED': {
return { status: 'exhausted' };
}
case 'BATCH_CANCELLED': {
return { status: 'cancelled' };
}
}
throw new Error(`Unexpected invite conflict: ${message}`);
}
@@ -178,6 +178,18 @@ describe(InviteService.name, () => {
});
});
describe('closeDrop', () => {
it('disables the drop button on the posted message', async () => {
await sut.closeDrop('batch-1', 'channel-1', 'message-1');
expect(mocks.discord.editMessage).toHaveBeenCalledWith(
'channel-1',
'message-1',
expect.objectContaining({ components: [expect.anything()] }),
);
});
});
describe('onClaimInvite', () => {
it('replies with a personal link on a successful claim', async () => {
mocks.api.createInvite.mockResolvedValue({ ...okInvite, remaining: 3 });
@@ -224,6 +236,18 @@ describe(InviteService.name, () => {
});
});
it('reports a cancelled drop and disables the button', async () => {
mocks.api.createInvite.mockResolvedValue({ status: 'cancelled' });
const interaction = newClaimInteraction();
await sut.onClaimInvite(interaction);
expect((interaction as { message: { edit: jest.Mock } }).message.edit).toHaveBeenCalled();
expect((interaction as { editReply: jest.Mock }).editReply).toHaveBeenCalledWith({
content: Messages.claimDropEnded,
});
});
it('reports a spent invite', async () => {
mocks.api.createInvite.mockResolvedValue({ status: 'invite-used' });
const interaction = newClaimInteraction();
@@ -131,6 +131,13 @@ export class InviteService {
});
return;
}
case 'cancelled': {
await interaction.message
.edit({ components: [this.claimRow(batchId, Messages.inviteDropEndedButton, true)] })
.catch((error: unknown) => this.logger.warn(error, 'could not disable the claim button'));
await interaction.editReply({ content: Messages.claimDropEnded });
return;
}
}
await interaction.editReply({
@@ -142,6 +149,12 @@ export class InviteService {
}
}
async closeDrop(batchId: string, channelId: string, messageId: string): Promise<void> {
await this.discord.editMessage(channelId, messageId, {
components: [this.claimRow(batchId, Messages.inviteDropEndedButton, true)],
});
}
private async disableClaimButton(interaction: ButtonInteraction, batchId: string): Promise<void> {
await interaction.message
.edit({ components: [this.claimRow(batchId, Messages.inviteDropClaimedButton, true)] })
+1
View File
@@ -24,6 +24,7 @@ export const newDiscordRepositoryMock = (): jest.Mocked<RepositoryInterface<Disc
listRecentMessages: jest.fn().mockResolvedValue([]),
sendMessage: jest.fn(),
sendDirectMessage: jest.fn().mockResolvedValue(true),
editMessage: jest.fn().mockResolvedValue(void 0),
createTicketThread: jest.fn(),
createStaffThread: jest.fn(),
listOpenTicketThreads: jest.fn().mockResolvedValue([]),
+236 -2
View File
@@ -899,6 +899,114 @@
]
}
},
"/api/discord-invites/batches": {
"get": {
"operationId": "listBatches",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DiscordInviteBatchListResponseDto"
}
}
}
}
},
"tags": [
"DiscordInvite"
]
}
},
"/api/discord-invites/batches/{id}": {
"delete": {
"operationId": "cancelBatch",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "revokeUnused",
"required": false,
"in": "query",
"description": "Also delete the batch’s unredeemed claims",
"schema": {
"type": "string",
"enum": [
"true",
"false"
]
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DiscordInviteBatchCancelResponseDto"
}
}
}
}
},
"tags": [
"DiscordInvite"
]
}
},
"/api/discord-invites": {
"get": {
"operationId": "listClaims",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DiscordInviteClaimListResponseDto"
}
}
}
}
},
"tags": [
"DiscordInvite"
]
}
},
"/api/discord-invites/{discordUserId}": {
"delete": {
"operationId": "revokeClaim",
"parameters": [
{
"name": "discordUserId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
}
},
"tags": [
"DiscordInvite"
]
}
},
"/api/settings": {
"get": {
"operationId": "listSettings",
@@ -1596,7 +1704,8 @@
"type": "string"
},
"email": {
"type": "string"
"type": "string",
"nullable": true
},
"inviteCode": {
"type": "string"
@@ -1621,7 +1730,6 @@
},
"required": [
"id",
"email",
"inviteCode",
"invited",
"inviteUsed",
@@ -1711,6 +1819,132 @@
"count"
]
},
"DiscordInviteBatchDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"guildId": {
"type": "string"
},
"channelId": {
"type": "string"
},
"messageId": {
"type": "string",
"nullable": true
},
"maxClaims": {
"type": "number"
},
"claimed": {
"type": "number"
},
"used": {
"type": "number"
},
"createdByDiscordUserId": {
"type": "string"
},
"cancelledAt": {
"type": "string",
"nullable": true
},
"createdAt": {
"type": "string"
}
},
"required": [
"id",
"guildId",
"channelId",
"maxClaims",
"claimed",
"used",
"createdByDiscordUserId",
"createdAt"
]
},
"DiscordInviteBatchListResponseDto": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DiscordInviteBatchDto"
}
}
},
"required": [
"items"
]
},
"DiscordInviteBatchCancelResponseDto": {
"type": "object",
"properties": {
"batch": {
"$ref": "#/components/schemas/DiscordInviteBatchDto"
},
"revokedClaims": {
"type": "number",
"description": "Unredeemed claims deleted by --revoke-unused"
}
},
"required": [
"batch",
"revokedClaims"
]
},
"DiscordInviteClaimDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"discordUserId": {
"type": "string"
},
"discordUsername": {
"type": "string",
"nullable": true
},
"batchId": {
"type": "string",
"nullable": true
},
"inviteUsed": {
"type": "boolean"
},
"inviteUsedAt": {
"type": "string",
"nullable": true
},
"createdAt": {
"type": "string"
}
},
"required": [
"id",
"discordUserId",
"inviteUsed",
"createdAt"
]
},
"DiscordInviteClaimListResponseDto": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DiscordInviteClaimDto"
}
}
},
"required": [
"items"
]
},
"SettingsValueDto": {
"type": "object",
"properties": {
@@ -7,6 +7,7 @@ import { KyselyModule } from 'nestjs-kysely';
import { createPublicKey } from 'node:crypto';
import { AllowlistController } from './controllers/allowlist.controller';
import { AuthController } from './controllers/auth.controller';
import { DiscordInviteController } from './controllers/discordInvite.controller';
import { FeaturesController } from './controllers/features.controller';
import { RepositoryController } from './controllers/repository.controller';
import { SessionController } from './controllers/session.controller';
@@ -16,8 +17,10 @@ import { env } from './env';
import { AuthGuard } from './middleware/auth.guard';
import { ConnectionRepository } from './repositories/connection.repository';
import { DatabaseRepository } from './repositories/database.repository';
import { DiscordInviteRepository } from './repositories/discordInvite.repository';
import { DiscordLinkRepository } from './repositories/discordLink.repository';
import { FeatureFlagRepository } from './repositories/featureFlag.repository';
import { FutoBackupsBotRepository } from './repositories/futoBackupsBot.repository';
import { OidcRepository } from './repositories/oidc.repository';
import { RepositoryRepository } from './repositories/repository.repository';
import { SessionRepository } from './repositories/session.repository';
@@ -29,6 +32,7 @@ import { UserAllowlistRepository } from './repositories/userAllowlist.repository
import { AllowlistService } from './services/allowlist.service';
import { AuthService } from './services/auth.service';
import { DatabaseService } from './services/database.service';
import { DiscordInviteService } from './services/discordInvite.service';
import { FeaturesService } from './services/features.service';
import { RepositoryService } from './services/repository.service';
import { SessionService } from './services/session.service';
@@ -56,6 +60,7 @@ export const controllers = [
SessionController,
RepositoryController,
AllowlistController,
DiscordInviteController,
SettingsController,
FeaturesController,
];
@@ -65,7 +70,9 @@ export const providers = [
LoggerRepository,
EmailRepository,
DatabaseRepository,
DiscordInviteRepository,
DiscordLinkRepository,
FutoBackupsBotRepository,
DatabaseService,
OidcRepository,
UserRepository,
@@ -78,6 +85,7 @@ export const providers = [
ConnectionRepository,
FeatureFlagRepository,
AllowlistService,
DiscordInviteService,
AuthService,
UserService,
SessionService,
@@ -0,0 +1,46 @@
import { Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import { ApiOkResponse } from '@nestjs/swagger';
import {
DiscordInviteBatchCancelQueryDto,
DiscordInviteBatchCancelResponseDto,
DiscordInviteBatchListResponseDto,
DiscordInviteClaimListResponseDto,
} from 'src/dto/discordInvite.dto';
import { AuthRoute } from 'src/middleware/auth.guard';
import { DiscordInviteService } from 'src/services/discordInvite.service';
@Controller('/discord-invites')
export class DiscordInviteController {
constructor(private readonly invites: DiscordInviteService) {}
@Get('/batches')
@AuthRoute()
@ApiOkResponse({ type: DiscordInviteBatchListResponseDto })
listBatches(): Promise<DiscordInviteBatchListResponseDto> {
return this.invites.listBatches();
}
@Delete('/batches/:id')
@AuthRoute()
@ApiOkResponse({ type: DiscordInviteBatchCancelResponseDto })
cancelBatch(
@Param('id', ParseUUIDPipe) id: string,
@Query() query: DiscordInviteBatchCancelQueryDto,
): Promise<DiscordInviteBatchCancelResponseDto> {
return this.invites.cancelBatch(id, query.revokeUnused === 'true');
}
@Get()
@AuthRoute()
@ApiOkResponse({ type: DiscordInviteClaimListResponseDto })
listClaims(): Promise<DiscordInviteClaimListResponseDto> {
return this.invites.listClaims();
}
@Delete('/:discordUserId')
@AuthRoute()
@HttpCode(HttpStatus.NO_CONTENT)
revokeClaim(@Param('discordUserId') discordUserId: string): Promise<void> {
return this.invites.revokeClaim(discordUserId);
}
}
@@ -0,0 +1,82 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator';
export class DiscordInviteClaimDto {
@ApiProperty()
id!: string;
@ApiProperty()
discordUserId!: string;
@ApiProperty({ type: 'string', required: false, nullable: true })
discordUsername!: string | null;
@ApiProperty({ type: 'string', required: false, nullable: true })
batchId!: string | null;
@ApiProperty()
inviteUsed!: boolean;
@ApiProperty({ type: 'string', required: false, nullable: true })
inviteUsedAt!: Date | null;
@ApiProperty({ type: 'string' })
createdAt!: Date;
}
export class DiscordInviteClaimListResponseDto {
@ApiProperty({ type: [DiscordInviteClaimDto] })
items!: DiscordInviteClaimDto[];
}
export class DiscordInviteBatchDto {
@ApiProperty()
id!: string;
@ApiProperty()
guildId!: string;
@ApiProperty()
channelId!: string;
@ApiProperty({ type: 'string', required: false, nullable: true })
messageId!: string | null;
@ApiProperty()
maxClaims!: number;
@ApiProperty()
claimed!: number;
@ApiProperty()
used!: number;
@ApiProperty()
createdByDiscordUserId!: string;
@ApiProperty({ type: 'string', required: false, nullable: true })
cancelledAt!: Date | null;
@ApiProperty({ type: 'string' })
createdAt!: Date;
}
export class DiscordInviteBatchListResponseDto {
@ApiProperty({ type: [DiscordInviteBatchDto] })
items!: DiscordInviteBatchDto[];
}
export class DiscordInviteBatchCancelQueryDto {
@ApiProperty({ required: false, enum: ['true', 'false'], description: 'Also delete the batch’s unredeemed claims' })
@IsOptional()
@IsIn(['true', 'false'])
revokeUnused?: string;
}
export class DiscordInviteBatchCancelResponseDto {
@ApiProperty({ type: DiscordInviteBatchDto })
batch!: DiscordInviteBatchDto;
@ApiProperty({ description: 'Unredeemed claims deleted by --revoke-unused' })
revokedClaims!: number;
}
+3
View File
@@ -36,6 +36,9 @@ const schema = z.object({
WEB_BASE_URL: z.url().default('http://localhost:5173'),
FUTO_BACKUPS_BOT_URL: z.string().default(''),
INTERNAL_SECRET: z.string().default(''),
OIDC_ADMIN_ISSUER: z.url().transform((url) => new URL(url)),
OIDC_ADMIN_CLIENT_ID: z.string(),
OIDC_ADMIN_CLIENT_SECRET: z.string(),
@@ -0,0 +1,99 @@
import { Injectable } from '@nestjs/common';
import { Kysely, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DB } from 'src/schema';
@Injectable()
export class DiscordInviteRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
listClaims() {
return this.db
.selectFrom('userAllowlist')
.selectAll()
.where('discordUserId', 'is not', null)
.orderBy('createdAt', 'desc')
.execute();
}
getClaim(discordUserId: string) {
return this.db
.selectFrom('userAllowlist')
.selectAll()
.where('discordUserId', '=', discordUserId)
.executeTakeFirst();
}
deleteClaim(id: string, discordUserId: string): Promise<'deleted' | 'linked' | 'used'> {
return this.db.transaction().execute(async (trx) => {
// Linking holds pg_advisory_xact_lock(hashtext(discordUserId)); taking it
// here serializes revocation against an in-flight redemption, whose link
// row lands before markUsed does.
await sql`SELECT pg_advisory_xact_lock(hashtext(${discordUserId}))`.execute(trx);
const link = await trx
.selectFrom('discordLinks')
.select('id')
.where('discordUserId', '=', discordUserId)
.executeTakeFirst();
if (link) {
return 'linked';
}
const deleted = await trx
.deleteFrom('userAllowlist')
.where('id', '=', sql<string>`${id}::uuid`)
.where('inviteUsed', '=', false)
.returning('id')
.executeTakeFirst();
return deleted === undefined ? 'used' : 'deleted';
});
}
listBatches() {
return this.batchesWithCounts().execute();
}
getBatch(id: string) {
return this.batchesWithCounts()
.where('discordInviteBatches.id', '=', sql<string>`${id}::uuid`)
.executeTakeFirst();
}
private batchesWithCounts() {
return this.db
.selectFrom('discordInviteBatches')
.leftJoin('userAllowlist', 'userAllowlist.batchId', 'discordInviteBatches.id')
.selectAll('discordInviteBatches')
.select((eb) => [
eb.fn.count<number>('userAllowlist.id').as('claimed'),
eb.fn.count<number>('userAllowlist.id').filterWhere('userAllowlist.inviteUsed', '=', true).as('used'),
])
.groupBy('discordInviteBatches.id')
.orderBy('discordInviteBatches.createdAt', 'desc');
}
cancelBatch(id: string, revokeUnused: boolean): Promise<number> {
return this.db.transaction().execute(async (trx) => {
// Claim transactions hold pg_advisory_xact_lock(hashtext(batchId));
// taking it here fences out in-flight claims so none can slip in after
// the cancellation and unused-claim sweep.
await sql`SELECT pg_advisory_xact_lock(hashtext(${id}))`.execute(trx);
await trx
.updateTable('discordInviteBatches')
.set({ cancelledAt: new Date() })
.where('id', '=', sql<string>`${id}::uuid`)
.where('cancelledAt', 'is', null)
.execute();
if (!revokeUnused) {
return 0;
}
const deleted = await trx
.deleteFrom('userAllowlist')
.where('batchId', '=', sql<string>`${id}::uuid`)
.where('inviteUsed', '=', false)
.where('discordUserId', 'is not', null)
.returning('id')
.execute();
return deleted.length;
});
}
}
@@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
import { env } from 'src/env';
@Injectable()
export class FutoBackupsBotRepository {
get enabled(): boolean {
return Boolean(env.FUTO_BACKUPS_BOT_URL);
}
async closeDrop(batchId: string, channelId: string, messageId: string): Promise<void> {
const response = await fetch(new URL('/internal/drops/close', env.FUTO_BACKUPS_BOT_URL), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Internal-Secret': env.INTERNAL_SECRET,
},
body: JSON.stringify({ batchId, channelId, messageId }),
});
if (!response.ok) {
throw new Error(`futo-backups-bot POST /internal/drops/close failed: ${response.status} ${response.statusText}`);
}
}
}
@@ -0,0 +1,122 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import { DiscordInviteService } from 'src/services/discordInvite.service';
import { Mocks, newMocks } from '../../test/mocks';
const claim = {
id: 'claim-1',
email: null,
inviteCode: 'code',
invited: true,
inviteUsed: false,
inviteUsedAt: null,
inviteEmailSentAt: null,
discordUserId: '123456789',
discordUsername: 'someone',
batchId: 'batch-1',
createdAt: new Date(),
};
const batch = {
id: 'batch-1',
guildId: 'guild-1',
channelId: 'channel-1',
messageId: 'message-1',
maxClaims: 10,
createdByDiscordUserId: 'staff-1',
cancelledAt: null,
createdAt: new Date(),
claimed: 3,
used: 1,
};
describe(DiscordInviteService.name, () => {
let sut: DiscordInviteService;
let mocks: Mocks;
beforeEach(() => {
mocks = newMocks();
sut = new DiscordInviteService(mocks.discordInvite as never, mocks.bot as never, mocks.logger as never);
});
describe('revokeClaim', () => {
it('deletes an unredeemed claim', async () => {
mocks.discordInvite.getClaim.mockResolvedValue(claim);
await sut.revokeClaim('123456789');
expect(mocks.discordInvite.deleteClaim).toHaveBeenCalledWith('claim-1', '123456789');
});
it('rejects an unknown claim', async () => {
mocks.discordInvite.getClaim.mockResolvedValue(void 0);
await expect(sut.revokeClaim('123456789')).rejects.toBeInstanceOf(NotFoundException);
});
it('refuses a redeemed claim', async () => {
mocks.discordInvite.getClaim.mockResolvedValue({ ...claim, inviteUsed: true });
await expect(sut.revokeClaim('123456789')).rejects.toBeInstanceOf(ConflictException);
expect(mocks.discordInvite.deleteClaim).not.toHaveBeenCalled();
});
it('refuses a claim redeemed between the read and the delete', async () => {
mocks.discordInvite.getClaim.mockResolvedValue(claim);
mocks.discordInvite.deleteClaim.mockResolvedValue('used');
await expect(sut.revokeClaim('123456789')).rejects.toBeInstanceOf(ConflictException);
});
it('refuses a claim whose discord account got linked mid-redemption', async () => {
mocks.discordInvite.getClaim.mockResolvedValue(claim);
mocks.discordInvite.deleteClaim.mockResolvedValue('linked');
await expect(sut.revokeClaim('123456789')).rejects.toBeInstanceOf(ConflictException);
});
});
describe('cancelBatch', () => {
beforeEach(() => {
mocks.discordInvite.getBatch.mockResolvedValue(batch);
});
it('soft-cancels and eagerly closes the drop message', async () => {
await expect(sut.cancelBatch('batch-1', false)).resolves.toEqual(
expect.objectContaining({ revokedClaims: 0, batch: expect.objectContaining({ claimed: 3, used: 1 }) }),
);
expect(mocks.discordInvite.cancelBatch).toHaveBeenCalledWith('batch-1', false);
expect(mocks.bot.closeDrop).toHaveBeenCalledWith('batch-1', 'channel-1', 'message-1');
});
it('revokes unredeemed claims when asked', async () => {
mocks.discordInvite.cancelBatch.mockResolvedValue(2);
await expect(sut.cancelBatch('batch-1', true)).resolves.toEqual(expect.objectContaining({ revokedClaims: 2 }));
expect(mocks.discordInvite.cancelBatch).toHaveBeenCalledWith('batch-1', true);
});
it('survives a failed bot notification', async () => {
mocks.bot.closeDrop.mockRejectedValue(new Error('bot down'));
await expect(sut.cancelBatch('batch-1', false)).resolves.toBeDefined();
expect(mocks.logger.warn).toHaveBeenCalled();
});
it('skips the bot when the batch has no posted message', async () => {
mocks.discordInvite.getBatch.mockResolvedValue({ ...batch, messageId: null });
await sut.cancelBatch('batch-1', false);
expect(mocks.bot.closeDrop).not.toHaveBeenCalled();
});
it('rejects an unknown batch', async () => {
mocks.discordInvite.getBatch.mockResolvedValue(void 0);
await expect(sut.cancelBatch('nope', false)).rejects.toBeInstanceOf(NotFoundException);
});
});
});
@@ -0,0 +1,109 @@
import { LoggerRepository } from '@common/server/otel';
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import {
DiscordInviteBatchCancelResponseDto,
DiscordInviteBatchDto,
DiscordInviteBatchListResponseDto,
DiscordInviteClaimDto,
DiscordInviteClaimListResponseDto,
} from 'src/dto/discordInvite.dto';
import { DiscordInviteRepository } from 'src/repositories/discordInvite.repository';
import { FutoBackupsBotRepository } from 'src/repositories/futoBackupsBot.repository';
@Injectable()
export class DiscordInviteService {
constructor(
private readonly invites: DiscordInviteRepository,
private readonly bot: FutoBackupsBotRepository,
private readonly logger: LoggerRepository,
) {}
async listClaims(): Promise<DiscordInviteClaimListResponseDto> {
const claims = await this.invites.listClaims();
return {
items: claims.map(
(claim): DiscordInviteClaimDto => ({
id: claim.id,
discordUserId: claim.discordUserId!,
discordUsername: claim.discordUsername,
batchId: claim.batchId,
inviteUsed: claim.inviteUsed,
inviteUsedAt: claim.inviteUsedAt,
createdAt: claim.createdAt,
}),
),
};
}
async revokeClaim(discordUserId: string): Promise<void> {
const claim = await this.invites.getClaim(discordUserId);
if (!claim) {
throw new NotFoundException(`No invite claim for Discord user ${discordUserId}`);
}
const result = claim.inviteUsed ? 'used' : await this.invites.deleteClaim(claim.id, discordUserId);
switch (result) {
case 'used': {
throw new ConflictException('Invite already redeemed — manage the account (disable / unlink-discord) instead');
}
case 'linked': {
throw new ConflictException(
'Discord account is already linked — manage the account (disable / unlink-discord) instead',
);
}
case 'deleted': {
return;
}
}
}
async listBatches(): Promise<DiscordInviteBatchListResponseDto> {
const batches = await this.invites.listBatches();
return { items: batches.map((batch) => this.toBatchDto(batch)) };
}
async cancelBatch(id: string, revokeUnused: boolean): Promise<DiscordInviteBatchCancelResponseDto> {
const batch = await this.invites.getBatch(id);
if (!batch) {
throw new NotFoundException(`No invite batch with id ${id}`);
}
const revokedClaims = await this.invites.cancelBatch(id, revokeUnused);
if (this.bot.enabled && batch.messageId) {
await this.bot
.closeDrop(batch.id, batch.channelId, batch.messageId)
.catch((error: unknown) =>
this.logger.warn(error, 'could not close the drop message — the bot disables it on the next click'),
);
}
const updated = await this.invites.getBatch(id);
return { batch: this.toBatchDto(updated!), revokedClaims };
}
private toBatchDto(batch: {
id: string;
guildId: string;
channelId: string;
messageId: string | null;
maxClaims: number;
createdByDiscordUserId: string;
cancelledAt: Date | null;
createdAt: Date;
claimed: number | string | bigint;
used: number | string | bigint;
}): DiscordInviteBatchDto {
return {
id: batch.id,
guildId: batch.guildId,
channelId: batch.channelId,
messageId: batch.messageId,
maxClaims: batch.maxClaims,
claimed: Number(batch.claimed),
used: Number(batch.used),
createdByDiscordUserId: batch.createdByDiscordUserId,
cancelledAt: batch.cancelledAt,
createdAt: batch.createdAt,
};
}
}
+22
View File
@@ -1,7 +1,9 @@
import type { EmailRepository } from '@common/server/email';
import type { LoggerRepository, WideContextRepository } from '@common/server/otel';
import type { DatabaseRepository } from 'src/repositories/database.repository';
import type { DiscordInviteRepository } from 'src/repositories/discordInvite.repository';
import type { DiscordLinkRepository } from 'src/repositories/discordLink.repository';
import type { FutoBackupsBotRepository } from 'src/repositories/futoBackupsBot.repository';
import type { OidcRepository } from 'src/repositories/oidc.repository';
import type { SessionRepository } from 'src/repositories/session.repository';
import type { UserRepository } from 'src/repositories/user.repository';
@@ -9,6 +11,24 @@ import type { UserAllowlistRepository } from 'src/repositories/userAllowlist.rep
export type RepositoryInterface<T extends object> = Pick<T, keyof T>;
export const newDiscordInviteRepositoryMock = (): jest.Mocked<RepositoryInterface<DiscordInviteRepository>> => {
return {
listClaims: jest.fn().mockResolvedValue([]),
getClaim: jest.fn(),
deleteClaim: jest.fn().mockResolvedValue('deleted'),
listBatches: jest.fn().mockResolvedValue([]),
getBatch: jest.fn(),
cancelBatch: jest.fn().mockResolvedValue(0),
};
};
export const newFutoBackupsBotRepositoryMock = (): jest.Mocked<RepositoryInterface<FutoBackupsBotRepository>> => {
return {
enabled: true,
closeDrop: jest.fn().mockResolvedValue(void 0),
};
};
export const newDatabaseRepositoryMock = (): jest.Mocked<RepositoryInterface<DatabaseRepository>> => {
return {
shutdown: jest.fn(),
@@ -94,6 +114,8 @@ export const newMetricServiceMock = () => ({
export const newMocks = () => {
return {
discordInvite: newDiscordInviteRepositoryMock(),
bot: newFutoBackupsBotRepositoryMock(),
database: newDatabaseRepositoryMock(),
discordLink: newDiscordLinkRepositoryMock(),
user: newUserRepositoryMock(),
@@ -7,7 +7,7 @@ import { DiscordLinkRequestTable } from 'src/schema/tables/discordLinkRequest.ta
import { UserAllowlistTable } from 'src/schema/tables/userAllowlist.table';
export type InviteClaimResult =
| { status: 'linked' | 'used' | 'unknownBatch' | 'exhausted' }
| { status: 'linked' | 'used' | 'unknownBatch' | 'exhausted' | 'cancelled' }
| { status: 'ok'; entry: Selectable<UserAllowlistTable>; remaining: number | null };
@Injectable()
@@ -109,7 +109,12 @@ export class DiscordRepository {
return updated !== undefined;
}
claimInvite(discordUserId: string, batchId: string | null, inviteCode: string): Promise<InviteClaimResult> {
claimInvite(
discordUserId: string,
discordUsername: string,
batchId: string | null,
inviteCode: string,
): Promise<InviteClaimResult> {
return this.db.transaction().execute(async (trx): Promise<InviteClaimResult> => {
const lockKey = batchId ?? discordUserId;
await sql`SELECT pg_advisory_xact_lock(least(hashtext(${discordUserId}), hashtext(${lockKey}))),
@@ -143,6 +148,9 @@ export class DiscordRepository {
if (!batch) {
return { status: 'unknownBatch' };
}
if (batch.cancelledAt) {
return { status: 'cancelled' };
}
const { claimed } = await trx
.selectFrom('userAllowlist')
.select((eb) => eb.fn.countAll<number>().as('claimed'))
@@ -156,7 +164,7 @@ export class DiscordRepository {
const entry = await trx
.insertInto('userAllowlist')
.values({ inviteCode, invited: true, discordUserId, batchId })
.values({ inviteCode, invited: true, discordUserId, discordUsername, batchId })
.returningAll()
.executeTakeFirstOrThrow();
return { status: 'ok', entry, remaining };
@@ -0,0 +1,11 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "discordInviteBatches" ADD "cancelledAt" timestamp with time zone;`.execute(db);
await sql`ALTER TABLE "userAllowlist" ADD "discordUsername" character varying;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "userAllowlist" DROP COLUMN "discordUsername";`.execute(db);
await sql`ALTER TABLE "discordInviteBatches" DROP COLUMN "cancelledAt";`.execute(db);
}
@@ -20,6 +20,9 @@ export class DiscordInviteBatchTable {
@Column()
createdByDiscordUserId!: string;
@Column({ type: 'timestamp with time zone', nullable: true })
cancelledAt!: Date | null;
@Column({ type: 'timestamp with time zone', default: () => 'now()' })
createdAt!: Generated<Date>;
}
@@ -27,6 +27,9 @@ export class UserAllowlistTable {
@Column({ unique: true, nullable: true })
discordUserId!: string | null;
@Column({ nullable: true })
discordUsername!: string | null;
@ForeignKeyColumn(() => DiscordInviteBatchTable, {
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
@@ -29,7 +29,7 @@ describe(AuthService.name, () => {
beforeEach(() => {
mocks = newMocks();
sut = new AuthService(
mocks.jwt as never,
mocks.logger as never,
mocks.oidc as never,
mocks.user as never,
mocks.userAllowlist as never,
@@ -481,6 +481,7 @@ describe(AuthService.name, () => {
it('should allow a new user with a valid discord invite, link the account, and mark the claim used', async () => {
mocks.discord.consumeInviteRequest.mockResolvedValue(inviteRequest);
mocks.userAllowlist.markUsed.mockResolvedValue({ id: 'entry' } as never);
await expect(sut.getOrCreateUser(claims, undefined, 'token')).resolves.toBe(mockUser);
@@ -500,9 +501,19 @@ describe(AuthService.name, () => {
expect(mocks.user.create).not.toHaveBeenCalled();
});
it('should warn instead of failing when the claim was revoked mid-redemption', async () => {
mocks.discord.consumeInviteRequest.mockResolvedValue(inviteRequest);
mocks.userAllowlist.markUsed.mockResolvedValue(void 0);
await expect(sut.getOrCreateUser(claims, undefined, 'token')).resolves.toBe(mockUser);
expect(mocks.logger.warn).toHaveBeenCalled();
});
it('should link an existing user who redeems a discord invite', async () => {
mocks.user.getBySub.mockResolvedValue(mockUser);
mocks.discord.consumeInviteRequest.mockResolvedValue(inviteRequest);
mocks.userAllowlist.markUsed.mockResolvedValue({ id: 'entry' } as never);
await expect(sut.getOrCreateUser(claims, undefined, 'token')).resolves.toEqual(mockUser);
@@ -171,7 +171,12 @@ export class AuthService {
if (invite?.allowlistId) {
await this.discord.linkDirect(user.id, invite.discordUserId, invite.discordUsername);
await this.allowlist.markUsed(invite.allowlistId);
if (!(await this.allowlist.markUsed(invite.allowlistId))) {
this.logger.warn(
{ userId: user.id, discordUserId: invite.discordUserId },
'invite claim was revoked mid-redemption — the account exists without a claim record',
);
}
}
return user;
@@ -130,7 +130,7 @@ describe(DiscordService.name, () => {
sut.createInvite({ discordUserId: '123456789', discordUsername: 'someone', batchId: 'batch' }),
).resolves.toEqual({ code: 'token', expiresAt: request.expiresAt, remaining: 2 });
expect(mocks.discord.claimInvite).toHaveBeenCalledWith('123456789', 'batch', 'invite-code');
expect(mocks.discord.claimInvite).toHaveBeenCalledWith('123456789', 'someone', 'batch', 'invite-code');
expect(mocks.discord.createRequest).toHaveBeenCalledWith(expect.objectContaining({ allowlistId: 'entry' }));
});
@@ -148,6 +148,7 @@ describe(DiscordService.name, () => {
['linked', 'ALREADY_LINKED'],
['used', 'INVITE_USED'],
['exhausted', 'BATCH_EXHAUSTED'],
['cancelled', 'BATCH_CANCELLED'],
] as const)('rejects a %s claim with a conflict', async (status, message) => {
mocks.discord.claimInvite.mockResolvedValue({ status });
@@ -91,7 +91,12 @@ export class DiscordService {
async createInvite(dto: DiscordInviteCreateDto): Promise<DiscordInviteCreatedDto> {
await this.discord.deleteExpiredRequests();
const claim = await this.discord.claimInvite(dto.discordUserId, dto.batchId ?? null, this.crypto.randomHex(16));
const claim = await this.discord.claimInvite(
dto.discordUserId,
dto.discordUsername,
dto.batchId ?? null,
this.crypto.randomHex(16),
);
switch (claim.status) {
case 'linked': {
throw new ConflictException('ALREADY_LINKED');
@@ -102,6 +107,9 @@ export class DiscordService {
case 'exhausted': {
throw new ConflictException('BATCH_EXHAUSTED');
}
case 'cancelled': {
throw new ConflictException('BATCH_CANCELLED');
}
case 'unknownBatch': {
throw new NotFoundException(`No invite batch with id ${dto.batchId}`);
}
+119
View File
@@ -0,0 +1,119 @@
package adminapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
)
// InviteClaim mirrors the admin-api DiscordInviteClaimDto.
type InviteClaim struct {
ID string `json:"id"`
DiscordUserID string `json:"discordUserId"`
DiscordUsername *string `json:"discordUsername"`
BatchID *string `json:"batchId"`
InviteUsed bool `json:"inviteUsed"`
InviteUsedAt *string `json:"inviteUsedAt"`
CreatedAt string `json:"createdAt"`
}
// InviteBatch mirrors the admin-api DiscordInviteBatchDto.
type InviteBatch struct {
ID string `json:"id"`
GuildID string `json:"guildId"`
ChannelID string `json:"channelId"`
MessageID *string `json:"messageId"`
MaxClaims int `json:"maxClaims"`
Claimed int `json:"claimed"`
Used int `json:"used"`
CreatedByDiscordUserID string `json:"createdByDiscordUserId"`
CancelledAt *string `json:"cancelledAt"`
CreatedAt string `json:"createdAt"`
}
// InviteBatchCancelResult is the DELETE /api/discord-invites/batches/:id envelope.
type InviteBatchCancelResult struct {
Batch InviteBatch `json:"batch"`
RevokedClaims int `json:"revokedClaims"`
}
// ListInviteClaims returns every Discord-minted beta invite claim.
func (c *Client) ListInviteClaims(ctx context.Context) ([]InviteClaim, error) {
var out struct {
Items []InviteClaim `json:"items"`
}
if err := c.getJSON(ctx, "/api/discord-invites", nil, &out); err != nil {
return nil, err
}
return out.Items, nil
}
// ListInviteBatches returns every invite drop with its claim counts.
func (c *Client) ListInviteBatches(ctx context.Context) ([]InviteBatch, error) {
var out struct {
Items []InviteBatch `json:"items"`
}
if err := c.getJSON(ctx, "/api/discord-invites/batches", nil, &out); err != nil {
return nil, err
}
return out.Items, nil
}
// RevokeInviteClaim deletes an unredeemed claim; the admin-api refuses claims
// that were already turned into accounts.
func (c *Client) RevokeInviteClaim(ctx context.Context, discordID string) error {
return c.deleteWithMessage(ctx, "/api/discord-invites/"+url.PathEscape(discordID), nil)
}
// CancelInviteBatch soft-cancels a drop (no further claims) and optionally
// deletes its unredeemed claims.
func (c *Client) CancelInviteBatch(ctx context.Context, batchID string, revokeUnused bool) (*InviteBatchCancelResult, error) {
path := "/api/discord-invites/batches/" + url.PathEscape(batchID)
if revokeUnused {
path += "?revokeUnused=true"
}
var out InviteBatchCancelResult
if err := c.deleteWithMessage(ctx, path, &out); err != nil {
return nil, err
}
return &out, nil
}
// deleteWithMessage issues a DELETE, decodes an optional JSON response into
// out, and surfaces the admin-api's error message on failure.
func (c *Client) deleteWithMessage(ctx context.Context, path string, out any) error {
u := c.baseURL + path
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
c.setAuth(req)
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("DELETE %s: %w", u, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("admin-api rejected the session token (status %d) — run `yuctl login --reauth`", resp.StatusCode)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var body struct {
Message string `json:"message"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err == nil && body.Message != "" {
return fmt.Errorf("DELETE %s: status %d: %s", u, resp.StatusCode, body.Message)
}
return fmt.Errorf("DELETE %s: status %d", u, resp.StatusCode)
}
if out == nil {
return nil
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("parse response: %w", err)
}
return nil
}
+163
View File
@@ -0,0 +1,163 @@
// Package invites manages Discord closed-beta invite claims and drops via the
// partition's yucca-admin-api.
package invites
import (
"fmt"
"text/tabwriter"
"github.com/spf13/cobra"
"yuctl/cmdutil"
)
func New(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "invites",
Short: "Discord closed-beta invite administration via yucca-admin-api",
}
cmd.AddCommand(
newListCmd(f),
newBatchesCmd(f),
newRevokeCmd(f),
newCancelCmd(f),
)
return cmd
}
func newListCmd(f *cmdutil.Factory) *cobra.Command {
admin := &cmdutil.AdminFlags{}
c := &cobra.Command{
Use: "list",
Short: "List Discord beta-invite claims",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
client, partition, err := admin.Client(ctx, f)
if err != nil {
return err
}
claims, err := client.ListInviteClaims(ctx)
if err != nil {
return err
}
w := tabwriter.NewWriter(f.IO.Out, 0, 2, 2, ' ', 0)
fmt.Fprintln(w, "DISCORD ID\tUSERNAME\tBATCH\tREDEEMED\tCLAIMED AT")
for _, claim := range claims {
username := ""
if claim.DiscordUsername != nil {
username = *claim.DiscordUsername
}
batch := ""
if claim.BatchID != nil {
batch = *claim.BatchID
}
redeemed := "no"
if claim.InviteUsed {
redeemed = "yes"
if claim.InviteUsedAt != nil {
redeemed = *claim.InviteUsedAt
}
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", claim.DiscordUserID, username, batch, redeemed, claim.CreatedAt)
}
w.Flush()
fmt.Fprintf(f.IO.Err, "\n%d claim(s) in partition %s\n", len(claims), partition)
return nil
},
}
admin.Register(c)
return c
}
func newBatchesCmd(f *cmdutil.Factory) *cobra.Command {
admin := &cmdutil.AdminFlags{}
c := &cobra.Command{
Use: "batches",
Short: "List invite drops with their claim counts",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
client, partition, err := admin.Client(ctx, f)
if err != nil {
return err
}
batches, err := client.ListInviteBatches(ctx)
if err != nil {
return err
}
w := tabwriter.NewWriter(f.IO.Out, 0, 2, 2, ' ', 0)
fmt.Fprintln(w, "ID\tCHANNEL\tCLAIMED\tREDEEMED\tCREATED BY\tCREATED AT\tCANCELLED")
for _, batch := range batches {
cancelled := ""
if batch.CancelledAt != nil {
cancelled = *batch.CancelledAt
}
fmt.Fprintf(w, "%s\t%s\t%d/%d\t%d\t%s\t%s\t%s\n",
batch.ID, batch.ChannelID, batch.Claimed, batch.MaxClaims, batch.Used,
batch.CreatedByDiscordUserID, batch.CreatedAt, cancelled)
}
w.Flush()
fmt.Fprintf(f.IO.Err, "\n%d batch(es) in partition %s\n", len(batches), partition)
return nil
},
}
admin.Register(c)
return c
}
func newRevokeCmd(f *cmdutil.Factory) *cobra.Command {
admin := &cmdutil.AdminFlags{}
var discordID string
c := &cobra.Command{
Use: "revoke",
Short: "Revoke an unredeemed beta-invite claim",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
client, _, err := admin.Client(cmd.Context(), f)
if err != nil {
return err
}
if err := client.RevokeInviteClaim(cmd.Context(), discordID); err != nil {
return err
}
fmt.Fprintf(f.IO.Out, "revoked the invite claim of discord user %s\n", discordID)
return nil
},
}
c.Flags().StringVar(&discordID, "discord-id", "", "discord user id (snowflake)")
_ = c.MarkFlagRequired("discord-id")
admin.Register(c)
return c
}
func newCancelCmd(f *cmdutil.Factory) *cobra.Command {
admin := &cmdutil.AdminFlags{}
var revokeUnused bool
c := &cobra.Command{
Use: "cancel <batch-id>",
Short: "Cancel an invite drop (stops further claims and disables its button)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, _, err := admin.Client(cmd.Context(), f)
if err != nil {
return err
}
result, err := client.CancelInviteBatch(cmd.Context(), args[0], revokeUnused)
if err != nil {
return err
}
fmt.Fprintf(f.IO.Out, "cancelled batch %s (%d/%d claimed, %d redeemed)\n",
result.Batch.ID, result.Batch.Claimed, result.Batch.MaxClaims, result.Batch.Used)
if revokeUnused {
fmt.Fprintf(f.IO.Out, "revoked %d unredeemed claim(s)\n", result.RevokedClaims)
}
return nil
},
}
c.Flags().BoolVar(&revokeUnused, "revoke-unused", false, "also delete the batch's unredeemed claims")
admin.Register(c)
return c
}
+2
View File
@@ -17,6 +17,7 @@ import (
configcmd "yuctl/cli/config"
featurescmd "yuctl/cli/features"
infracmd "yuctl/cli/infra"
invitescmd "yuctl/cli/invites"
toolscmd "yuctl/cli/tools"
userscmd "yuctl/cli/users"
"yuctl/cmdutil"
@@ -58,6 +59,7 @@ func NewRootCmd() *cobra.Command {
cephcmd.New(f),
infracmd.New(f),
userscmd.New(f),
invitescmd.New(f),
configcmd.New(f),
featurescmd.New(f),
toolscmd.New(f),