diff --git a/app/backend/package.json b/app/backend/package.json index d7cbea5c..6545632e 100644 --- a/app/backend/package.json +++ b/app/backend/package.json @@ -37,6 +37,7 @@ "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/schedule": "^5.0.1", "@nestjs/swagger": "^11.2.5", "@nestjs/terminus": "^11.0.0", "@nestjs/throttler": "^6.5.0", diff --git a/app/backend/prisma/migrations/20260429101500_add_claim_expiration/migration.sql b/app/backend/prisma/migrations/20260429101500_add_claim_expiration/migration.sql new file mode 100644 index 00000000..a5b4dce9 --- /dev/null +++ b/app/backend/prisma/migrations/20260429101500_add_claim_expiration/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "Claim" +ADD COLUMN "expiresAt" DATETIME; + +CREATE INDEX "Claim_expiresAt_idx" ON "Claim"("expiresAt"); diff --git a/app/backend/prisma/schema.prisma b/app/backend/prisma/schema.prisma index 75838b70..769716f1 100644 --- a/app/backend/prisma/schema.prisma +++ b/app/backend/prisma/schema.prisma @@ -225,6 +225,7 @@ model Claim { recipientRef String evidenceRef String? + expiresAt DateTime? /// Set when this claim is cancelled cancelledAt DateTime? @@ -243,6 +244,7 @@ model Claim { @@index([createdAt]) @@index([deletedAt]) @@index([reissuedFromId]) + @@index([expiresAt]) } enum PurgeStrategy { diff --git a/app/backend/src/app.module.ts b/app/backend/src/app.module.ts index 65205357..0e49e80b 100644 --- a/app/backend/src/app.module.ts +++ b/app/backend/src/app.module.ts @@ -2,6 +2,7 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { BullModule } from '@nestjs/bullmq'; import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core'; +import { ScheduleModule } from '@nestjs/schedule'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; @@ -82,6 +83,7 @@ import { DeprecationInterceptor } from './common/interceptors/deprecation.interc }), inject: [ConfigService], }), + ScheduleModule.forRoot(), LoggerModule, PrismaModule, diff --git a/app/backend/src/claims/claims.service.spec.ts b/app/backend/src/claims/claims.service.spec.ts index 7e5d5bb1..f6d95595 100644 --- a/app/backend/src/claims/claims.service.spec.ts +++ b/app/backend/src/claims/claims.service.spec.ts @@ -29,6 +29,7 @@ describe('ClaimsService', () => { amount: new Prisma.Decimal('100.00'), recipientRef: 'recipient-123', evidenceRef: 'evidence-456', + expiresAt: new Date(Date.now() + 3600_000), createdAt: new Date(), updatedAt: new Date(), campaign: { @@ -50,8 +51,22 @@ describe('ClaimsService', () => { amountDisbursed: '1000000000', metadata: { adapter: 'mock' }, }); - const mockOnchainAdapter: Partial = { + const mockOnchainAdapter: Partial & { + revokeAidPackage: jest.Mock; + refundAidPackage: jest.Mock; + } = { disburse: mockDisburse, + revokeAidPackage: jest.fn().mockResolvedValue({ + transactionHash: 'mock-revoke-hash', + timestamp: new Date(), + status: 'success' as const, + }), + refundAidPackage: jest.fn().mockResolvedValue({ + transactionHash: 'mock-refund-hash', + timestamp: new Date(), + status: 'success' as const, + amountRefunded: '1000000000', + }), }; const mockMetricsService = { @@ -379,4 +394,84 @@ describe('ClaimsService', () => { ); }); }); + + describe('cleanupExpiredClaims', () => { + it('archives requested and verified claims whose expiry has passed', async () => { + const expiredClaim = { + ...mockClaim, + status: ClaimStatus.requested, + expiresAt: new Date('2026-04-01T00:00:00.000Z'), + }; + + jest + .spyOn(prismaService.claim, 'findMany') + .mockResolvedValue([expiredClaim] as never); + jest.spyOn(prismaService.claim, 'update').mockResolvedValue({ + ...expiredClaim, + status: ClaimStatus.archived, + } as never); + + const result = await service.cleanupExpiredClaims( + new Date('2026-04-29T00:00:00.000Z'), + ); + + expect(result).toEqual({ processed: 1, archived: 1 }); + expect(prismaService.claim.findMany).toHaveBeenCalledWith({ + where: { + deletedAt: null, + status: { + in: [ClaimStatus.requested, ClaimStatus.verified], + }, + expiresAt: { + lt: new Date('2026-04-29T00:00:00.000Z'), + }, + }, + }); + expect(prismaService.claim.update).toHaveBeenCalledWith({ + where: { id: expiredClaim.id }, + data: { status: ClaimStatus.archived }, + }); + expect(mockAuditService.record).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'system', + entity: 'claim', + entityId: expiredClaim.id, + action: 'expired_cleanup', + }), + ); + }); + + it('skips cleanup gracefully when the adapter does not support revoke/refund', async () => { + const expiredClaim = { + ...mockClaim, + status: ClaimStatus.verified, + expiresAt: new Date('2026-04-01T00:00:00.000Z'), + }; + delete (mockOnchainAdapter as Record).revokeAidPackage; + delete (mockOnchainAdapter as Record).refundAidPackage; + + jest + .spyOn(prismaService.claim, 'findMany') + .mockResolvedValue([expiredClaim] as never); + jest.spyOn(prismaService.claim, 'update').mockResolvedValue({ + ...expiredClaim, + status: ClaimStatus.archived, + } as never); + + await service.cleanupExpiredClaims(new Date('2026-04-29T00:00:00.000Z')); + + expect(prismaService.claim.update).toHaveBeenCalled(); + expect(mockAuditService.record).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'expired_cleanup', + metadata: expect.objectContaining({ + onchain: expect.objectContaining({ + attempted: false, + skippedReason: 'adapter_missing_cleanup_methods', + }), + }), + }), + ); + }); + }); }); diff --git a/app/backend/src/claims/claims.service.ts b/app/backend/src/claims/claims.service.ts index 31cb1f76..0a7e4bdb 100644 --- a/app/backend/src/claims/claims.service.ts +++ b/app/backend/src/claims/claims.service.ts @@ -7,6 +7,7 @@ import { Logger, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { Cron, CronExpression } from '@nestjs/schedule'; import { createHash } from 'crypto'; import { PrismaService } from '../prisma/prisma.service'; import { CreateClaimDto } from './dto/create-claim.dto'; @@ -23,6 +24,26 @@ import { MetricsService } from '../observability/metrics/metrics.service'; import { AuditService } from '../audit/audit.service'; import { EncryptionService } from '../common/encryption/encryption.service'; +type ExpirationCleanupCapableAdapter = OnchainAdapter & { + revokeAidPackage?: (params: { + packageId: string; + operatorAddress: string; + }) => Promise<{ + transactionHash: string; + status: 'success' | 'failed'; + }>; + refundAidPackage?: (params: { + packageId: string; + operatorAddress: string; + }) => Promise<{ + transactionHash: string; + status: 'success' | 'failed'; + amountRefunded?: string; + }>; +}; + +const DEFAULT_CLAIM_EXPIRY_DAYS = 30; + @Injectable() export class ClaimsService { private readonly logger = new Logger(ClaimsService.name); @@ -60,6 +81,11 @@ export class ClaimsService { createClaimDto.recipientRef, ), evidenceRef: createClaimDto.evidenceRef, + expiresAt: + createClaimDto.expiresAt ?? + new Date( + Date.now() + DEFAULT_CLAIM_EXPIRY_DAYS * 24 * 60 * 60 * 1000, + ), // Store tokenAddress in metadata for multi-token support // Note: This would require a schema migration to add tokenAddress field // For now, we pass it to on-chain operations directly @@ -101,7 +127,9 @@ export class ClaimsService { }, }); // Type assertion for stale Prisma types - const claim = claimResult as typeof claimResult & { deletedAt: Date | null } | null; + const claim = claimResult as + | (typeof claimResult & { deletedAt: Date | null }) + | null; if (!claim || claim.deletedAt) { throw new NotFoundException('Claim not found'); } @@ -312,6 +340,116 @@ export class ClaimsService { ); } + @Cron(CronExpression.EVERY_HOUR) + async handleExpiredClaimsCron(): Promise { + try { + await this.cleanupExpiredClaims(); + } catch (error) { + this.logger.error( + 'Failed to clean up expired claims', + error instanceof Error ? error.stack : undefined, + ); + } + } + + async cleanupExpiredClaims(now: Date = new Date()): Promise<{ + processed: number; + archived: number; + }> { + const expiredClaims = await this.prisma.claim.findMany({ + where: { + deletedAt: null, + status: { + in: [ClaimStatus.requested, ClaimStatus.verified], + }, + expiresAt: { + lt: now, + }, + }, + }); + + if (expiredClaims.length === 0) { + this.logger.log('No expired claims found for cleanup'); + return { processed: 0, archived: 0 }; + } + + let archived = 0; + + for (const claim of expiredClaims) { + const onchainMetadata = await this.cleanupExpiredClaimOnchain(claim.id); + + await this.prisma.claim.update({ + where: { id: claim.id }, + data: { status: ClaimStatus.archived }, + }); + + await this.auditService.record({ + actorId: 'system', + entity: 'claim', + entityId: claim.id, + action: 'expired_cleanup', + metadata: { + previousStatus: claim.status, + nextStatus: ClaimStatus.archived, + expiresAt: claim.expiresAt?.toISOString() ?? null, + onchain: onchainMetadata, + }, + }); + + archived += 1; + } + + this.logger.log( + `Expired claim cleanup completed: archived ${archived} claim(s)`, + ); + + return { + processed: expiredClaims.length, + archived, + }; + } + + private async cleanupExpiredClaimOnchain(claimId: string): Promise<{ + attempted: boolean; + revoked?: string; + refunded?: string; + skippedReason?: string; + }> { + if (!this.onchainEnabled || !this.onchainAdapter) { + return { + attempted: false, + skippedReason: 'onchain_disabled', + }; + } + + const cleanupAdapter = this + .onchainAdapter as ExpirationCleanupCapableAdapter; + + if (!cleanupAdapter.revokeAidPackage || !cleanupAdapter.refundAidPackage) { + return { + attempted: false, + skippedReason: 'adapter_missing_cleanup_methods', + }; + } + + const packageId = this.generateMockPackageId(claimId); + + const revokeResult = await cleanupAdapter.revokeAidPackage({ + packageId, + operatorAddress: 'system', + }); + const refundResult = await cleanupAdapter.refundAidPackage({ + packageId, + operatorAddress: 'system', + }); + + return { + attempted: true, + revoked: revokeResult.transactionHash, + refunded: refundResult.transactionHash, + }; + } + private async transitionStatus( id: string, fromStatus: ClaimStatus, @@ -582,7 +720,11 @@ export class ClaimsService { if (query.tokenAddress) { // Check if either claim or campaign metadata contains the token address where.OR = [ - { campaign: { metadata: { path: 'tokenAddress', equals: query.tokenAddress } } }, + { + campaign: { + metadata: { path: 'tokenAddress', equals: query.tokenAddress }, + }, + }, ]; } @@ -620,7 +762,9 @@ export class ClaimsService { const data = claims.map(c => { const claimMetadata = c.metadata as Record | undefined; - const campaignMetadata = c.campaign?.metadata as Record | undefined; + const campaignMetadata = c.campaign?.metadata as + | Record + | undefined; return { id: c.id, @@ -636,7 +780,9 @@ export class ClaimsService { cancelReason: c.cancelReason ?? null, reissuedFromId: c.reissuedFromId ?? null, // Extract tokenAddress from metadata (keeping it secure - no decryption of recipientRef) - tokenAddress: (claimMetadata?.tokenAddress ?? campaignMetadata?.tokenAddress ?? null) as string | null, + tokenAddress: (claimMetadata?.tokenAddress ?? + campaignMetadata?.tokenAddress ?? + null) as string | null, }; }); @@ -665,22 +811,25 @@ export class ClaimsService { return `"${str}"`; }; - const header = 'id,campaignId,campaignName,status,amount,evidenceRef,createdAt,updatedAt,cancelledAt,cancelledBy,cancelReason,reissuedFromId,tokenAddress'; - const lines = rows.map(r => [ - escape(r.id), - escape(r.campaignId), - escape(r.campaignName), - escape(r.status), - escape(r.amount.toFixed(2)), - escape(r.evidenceRef), - escape(r.createdAt.toISOString()), - escape(r.updatedAt.toISOString()), - escape(r.cancelledAt?.toISOString() ?? ''), - escape(r.cancelledBy), - escape(r.cancelReason), - escape(r.reissuedFromId), - escape(r.tokenAddress), - ].join(',')); + const header = + 'id,campaignId,campaignName,status,amount,evidenceRef,createdAt,updatedAt,cancelledAt,cancelledBy,cancelReason,reissuedFromId,tokenAddress'; + const lines = rows.map(r => + [ + escape(r.id), + escape(r.campaignId), + escape(r.campaignName), + escape(r.status), + escape(r.amount.toFixed(2)), + escape(r.evidenceRef), + escape(r.createdAt.toISOString()), + escape(r.updatedAt.toISOString()), + escape(r.cancelledAt?.toISOString() ?? ''), + escape(r.cancelledBy), + escape(r.cancelReason), + escape(r.reissuedFromId), + escape(r.tokenAddress), + ].join(','), + ); return [header, ...lines].join('\r\n'); } diff --git a/app/backend/src/claims/dto/create-claim.dto.ts b/app/backend/src/claims/dto/create-claim.dto.ts index 4f8d7a45..b6605d03 100644 --- a/app/backend/src/claims/dto/create-claim.dto.ts +++ b/app/backend/src/claims/dto/create-claim.dto.ts @@ -5,6 +5,7 @@ import { IsOptional, Min, Matches, + IsDate, } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; @@ -57,4 +58,14 @@ export class CreateClaimDto { @IsOptional() @IsString() evidenceRef?: string; + + @ApiPropertyOptional({ + description: + 'When the claim should automatically expire if it remains unprocessed.', + example: '2026-05-31T23:59:59.000Z', + }) + @IsOptional() + @Type(() => Date) + @IsDate() + expiresAt?: Date; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3e120d0..5b83ff03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ importers: '@nestjs/platform-express': specifier: ^11.0.1 version: 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17) + '@nestjs/schedule': + specifier: ^5.0.1 + version: 5.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)) '@nestjs/swagger': specifier: ^11.2.5 version: 11.2.6(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) @@ -292,7 +295,7 @@ importers: version: 9.39.4(jiti@2.6.1) eslint-config-next: specifier: ^16.2.1 - version: 16.2.4(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 16.2.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) jest: specifier: ^30.3.0 version: 30.3.0(@types/node@20.19.37)(ts-node@10.9.2(@swc/core@1.15.30(@swc/helpers@0.5.15))(@types/node@20.19.37)(typescript@5.9.3)) @@ -1883,6 +1886,12 @@ packages: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 + '@nestjs/schedule@5.0.1': + resolution: {integrity: sha512-kFoel84I4RyS2LNPH6yHYTKxB16tb3auAEciFuc788C3ph6nABkUfzX5IE+unjVaRX+3GuruJwurNepMlHXpQg==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/schematics@11.0.9': resolution: {integrity: sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==} peerDependencies: @@ -3122,6 +3131,9 @@ packages: '@types/leaflet@1.9.21': resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==} + '@types/luxon@3.4.2': + resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} + '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} @@ -4224,6 +4236,9 @@ packages: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} engines: {node: '>=12.0.0'} + cron@3.5.0: + resolution: {integrity: sha512-0eYZqCnapmxYcV06uktql93wNWdlTmmBFP2iYz+JPVcQqlyFYcn1lFuIk4R54pkOmE7mcldTAPZv6X5XA4Q46A==} + cross-fetch@3.2.0: resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} @@ -6160,6 +6175,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + luxon@3.5.0: + resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} + engines: {node: '>=12'} + luxon@3.7.2: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} @@ -10512,6 +10531,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/schedule@5.0.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cron: 3.5.0 + '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)': dependencies: '@angular-devkit/core': 19.2.17(chokidar@4.0.3) @@ -11728,6 +11753,8 @@ snapshots: dependencies: '@types/geojson': 7946.0.16 + '@types/luxon@3.4.2': {} + '@types/methods@1.1.4': {} '@types/multer@2.1.0': @@ -13166,6 +13193,11 @@ snapshots: dependencies: luxon: 3.7.2 + cron@3.5.0: + dependencies: + '@types/luxon': 3.4.2 + luxon: 3.5.0 + cross-fetch@3.2.0: dependencies: node-fetch: 2.7.0 @@ -13514,13 +13546,13 @@ snapshots: - supports-color - typescript - eslint-config-next@16.2.4(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + eslint-config-next@16.2.4(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 16.2.4 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.6.1)) @@ -13557,7 +13589,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -13610,6 +13642,33 @@ snapshots: - eslint-import-resolver-webpack - supports-color + eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.6.1)): dependencies: aria-query: 5.3.2 @@ -15793,6 +15852,8 @@ snapshots: dependencies: react: 19.2.3 + luxon@3.5.0: {} + luxon@3.7.2: {} magic-string@0.30.17: