feat(admin-panel): signing keys management tab with usage tracking (#20586)

## Summary
- Adds a new admin-only **Security** tab to the Admin Panel (alongside
General/Apps/AI/Config/Health) containing a **Signing Keys** section.
The tab is intentionally introduced now so the upcoming **Encryption
rotation** work can land as a sibling section.
- Lists every JWT signing key with key id, `createdAt`, `revokedAt`,
current/active/revoked status, and a **7-day verification count** read
from Redis. A trailing row aggregates **legacy HS256** verifications so
it is clear when the deprecated path is still in use.
- Lets an admin **revoke** a public key. Revoking the current key drops
`isCurrent`, sets `revokedAt`, nulls the encrypted `privateKey` and
clears the in-process cached current key; the existing lazy path in
`JwtKeyManagerService.getCurrentSigningKey()` then mints a fresh current
key on the next sign.

## Backend
- `SigningKeyVerifyCounterService` — bucketed Redis counter under the
existing `EngineMetrics` namespace. 1-day UTC-aligned buckets, 8-day TTL
refreshed on every increment, batched read via `mget`. Failures are
swallowed and logged at `warn` so a Redis hiccup cannot break auth.
- `JwtWrapperService.verifyJwtToken` records verifies **after success**
for both ES256 (`kid` as identifier) and HS256 (the literal `legacy`
identifier).
- `JwtKeyManagerService.listSigningKeys()` and `revokeSigningKey(id)`:
list ordered by `isCurrent DESC, createdAt DESC`; revoke is idempotent,
validates the UUID, invalidates the public-key cache, and resets the
cached current-key promise.
- `AdminPanelResolver.getSigningKeys` (query) and `revokeSigningKey`
(mutation) are both decorated with `@UseGuards(AdminPanelGuard)` so they
are admin-only, like the 35 existing admin-only methods on this
resolver. `privateKey` is never returned over GraphQL.

## Frontend
- New `SECURITY` tab id wired into `SettingsAdminContent` and
`SettingsAdminTabContent` (gated by `canAccessFullAdminPanel`).
- `SettingsAdminSecurity` / `SettingsAdminSigningKeysTable` strictly
reuse existing admin-panel components: `Section`, `H2Title`,
`Table`/`TableRow`/`TableCell`/`TableHeader` from `@/ui/layout/table`,
`Tag`/`Button` from `twenty-ui`, and `ConfirmationModal` mirroring the
queue retry/delete modals. Only one minimal styled helper for the
monospaced UUID rendering.
- `useRevokeSigningKey` uses `useApolloAdminClient`, refetches
`GetSigningKeys`, shows success/error snackbars (same pattern as
`useRetryJobs`/`useDeleteJobs`).

<img width="1293" height="881" alt="image"
src="https://github.com/user-attachments/assets/7cf98664-950b-4451-af85-27781a8e9a9c"
/>
This commit is contained in:
Charles Bochet
2026-05-15 12:49:18 +02:00
committed by GitHub
parent 218799636f
commit 75b9b2fe5d
19 changed files with 1015 additions and 5 deletions
@@ -15,6 +15,7 @@ import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/main
import { AdminPanelBillingService } from 'src/engine/core-modules/admin-panel/services/admin-panel-billing.service';
import { AdminPanelChatService } from 'src/engine/core-modules/admin-panel/services/admin-panel-chat.service';
import { AdminPanelConfigService } from 'src/engine/core-modules/admin-panel/services/admin-panel-config.service';
import { AdminPanelSigningKeyService } from 'src/engine/core-modules/admin-panel/services/admin-panel-signing-key.service';
import { AdminPanelStatisticsService } from 'src/engine/core-modules/admin-panel/services/admin-panel-statistics.service';
import { AdminPanelUserLookupService } from 'src/engine/core-modules/admin-panel/services/admin-panel-user-lookup.service';
import { AdminPanelVersionService } from 'src/engine/core-modules/admin-panel/services/admin-panel-version.service';
@@ -29,6 +30,7 @@ import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { ImpersonationModule } from 'src/engine/core-modules/impersonation/impersonation.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { KeyValuePairModule } from 'src/engine/core-modules/key-value-pair/key-value-pair.module';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-client.module';
@@ -76,6 +78,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
UserVarsModule,
UpgradeModule,
UserModule,
JwtModule,
],
providers: [
AdminPanelResolver,
@@ -85,6 +88,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
AdminPanelBillingService,
AdminPanelChatService,
AdminPanelConfigService,
AdminPanelSigningKeyService,
AdminPanelVersionService,
AdminPanelHealthService,
AdminPanelQueueService,
@@ -25,6 +25,9 @@ import { ConfigVariablesDTO } from 'src/engine/core-modules/admin-panel/dtos/con
import { DeleteJobsResponseDTO } from 'src/engine/core-modules/admin-panel/dtos/delete-jobs-response.dto';
import { QueueJobsResponseDTO } from 'src/engine/core-modules/admin-panel/dtos/queue-jobs-response.dto';
import { RetryJobsResponseDTO } from 'src/engine/core-modules/admin-panel/dtos/retry-jobs-response.dto';
import { RevokeSigningKeyInput } from 'src/engine/core-modules/admin-panel/dtos/revoke-signing-key.input';
import { SigningKeyDTO } from 'src/engine/core-modules/admin-panel/dtos/signing-key.dto';
import { SigningKeysAdminPanelDTO } from 'src/engine/core-modules/admin-panel/dtos/signing-keys-admin-panel.dto';
import { SystemHealthDTO } from 'src/engine/core-modules/admin-panel/dtos/system-health.dto';
import { UpdateWorkspaceFeatureFlagInput } from 'src/engine/core-modules/admin-panel/dtos/update-workspace-feature-flag.input';
import { UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.dto';
@@ -37,6 +40,7 @@ import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/main
import { AdminPanelBillingService } from 'src/engine/core-modules/admin-panel/services/admin-panel-billing.service';
import { AdminPanelChatService } from 'src/engine/core-modules/admin-panel/services/admin-panel-chat.service';
import { AdminPanelConfigService } from 'src/engine/core-modules/admin-panel/services/admin-panel-config.service';
import { AdminPanelSigningKeyService } from 'src/engine/core-modules/admin-panel/services/admin-panel-signing-key.service';
import { AdminPanelStatisticsService } from 'src/engine/core-modules/admin-panel/services/admin-panel-statistics.service';
import { AdminPanelUserLookupService } from 'src/engine/core-modules/admin-panel/services/admin-panel-user-lookup.service';
import { AdminPanelVersionService } from 'src/engine/core-modules/admin-panel/services/admin-panel-version.service';
@@ -98,6 +102,7 @@ export class AdminPanelResolver {
private readonly adminConfigService: AdminPanelConfigService,
private readonly adminVersionService: AdminPanelVersionService,
private readonly adminPanelHealthService: AdminPanelHealthService,
private readonly adminPanelSigningKeyService: AdminPanelSigningKeyService,
private readonly applicationRegistrationService: ApplicationRegistrationService,
private adminPanelQueueService: AdminPanelQueueService,
private featureFlagService: FeatureFlagService,
@@ -729,4 +734,18 @@ export class AdminPanelResolver {
return this.upgradeStatusService.getWorkspaceStatuses(workspaceIds);
}
@UseGuards(AdminPanelGuard)
@Query(() => SigningKeysAdminPanelDTO)
async getSigningKeys(): Promise<SigningKeysAdminPanelDTO> {
return this.adminPanelSigningKeyService.getSigningKeys();
}
@UseGuards(AdminPanelGuard)
@Mutation(() => SigningKeyDTO)
async revokeSigningKey(
@Args() { id }: RevokeSigningKeyInput,
): Promise<SigningKeyDTO> {
return this.adminPanelSigningKeyService.revokeSigningKey(id);
}
}
@@ -0,0 +1,12 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ArgsType()
export class RevokeSigningKeyInput {
@Field(() => UUIDScalarType)
@IsNotEmpty()
@IsUUID()
id: string;
}
@@ -0,0 +1,24 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType()
export class SigningKeyDTO {
@Field(() => UUIDScalarType)
id: string;
@Field()
publicKey: string;
@Field()
isCurrent: boolean;
@Field(() => Date)
createdAt: Date;
@Field(() => Date, { nullable: true })
revokedAt: Date | null;
@Field(() => Int)
verifyCountInWindow: number;
}
@@ -0,0 +1,15 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { SigningKeyDTO } from 'src/engine/core-modules/admin-panel/dtos/signing-key.dto';
@ObjectType()
export class SigningKeysAdminPanelDTO {
@Field(() => [SigningKeyDTO])
signingKeys: SigningKeyDTO[];
@Field(() => Int)
legacyVerifyCountInWindow: number;
@Field(() => Int)
verifyWindowDays: number;
}
@@ -0,0 +1,53 @@
import { Injectable } from '@nestjs/common';
import { SigningKeyDTO } from 'src/engine/core-modules/admin-panel/dtos/signing-key.dto';
import { SigningKeysAdminPanelDTO } from 'src/engine/core-modules/admin-panel/dtos/signing-keys-admin-panel.dto';
import { type SigningKeyEntity } from 'src/engine/core-modules/jwt/entities/signing-key.entity';
import { JwtKeyManagerService } from 'src/engine/core-modules/jwt/services/jwt-key-manager.service';
import { SigningKeyVerifyCounterService } from 'src/engine/core-modules/jwt/services/signing-key-verify-counter.service';
@Injectable()
export class AdminPanelSigningKeyService {
constructor(
private readonly jwtKeyManagerService: JwtKeyManagerService,
private readonly signingKeyVerifyCounterService: SigningKeyVerifyCounterService,
) {}
async getSigningKeys(): Promise<SigningKeysAdminPanelDTO> {
const signingKeys = await this.jwtKeyManagerService.listSigningKeys();
const usage = await this.signingKeyVerifyCounterService.getUsageInWindow(
signingKeys.map((signingKey) => signingKey.id),
);
return {
signingKeys: signingKeys.map((signingKey) =>
this.toSigningKeyDTO(signingKey, usage.byKid[signingKey.id] ?? 0),
),
legacyVerifyCountInWindow: usage.legacyCount,
verifyWindowDays: usage.windowDays,
};
}
async revokeSigningKey(id: string): Promise<SigningKeyDTO> {
const revoked = await this.jwtKeyManagerService.revokeSigningKey(id);
const usage = await this.signingKeyVerifyCounterService.getUsageInWindow([
revoked.id,
]);
return this.toSigningKeyDTO(revoked, usage.byKid[revoked.id] ?? 0);
}
private toSigningKeyDTO(
signingKey: SigningKeyEntity,
verifyCountInWindow: number,
): SigningKeyDTO {
return {
id: signingKey.id,
publicKey: signingKey.publicKey,
isCurrent: signingKey.isCurrent,
createdAt: signingKey.createdAt,
revokedAt: signingKey.revokedAt,
verifyCountInWindow,
};
}
}
@@ -9,6 +9,7 @@ import {
export const JwtKeyManagerExceptionCode = appendCommonExceptionCode({
INVALID_PRIVATE_KEY: 'INVALID_PRIVATE_KEY',
SIGNING_KEY_NOT_FOUND: 'SIGNING_KEY_NOT_FOUND',
} as const);
const getJwtKeyManagerExceptionUserFriendlyMessage = (
@@ -16,6 +17,7 @@ const getJwtKeyManagerExceptionUserFriendlyMessage = (
): MessageDescriptor => {
switch (code) {
case JwtKeyManagerExceptionCode.INVALID_PRIVATE_KEY:
case JwtKeyManagerExceptionCode.SIGNING_KEY_NOT_FOUND:
case JwtKeyManagerExceptionCode.INTERNAL_SERVER_ERROR:
return STANDARD_ERROR_MESSAGE;
default:
@@ -11,6 +11,7 @@ import { SigningKeyEntity } from 'src/engine/core-modules/jwt/entities/signing-k
import { JwtKeyManagerService } from 'src/engine/core-modules/jwt/services/jwt-key-manager.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { SigningKeyEntityCacheProviderService } from 'src/engine/core-modules/jwt/services/signing-key-entity-cache-provider.service';
import { SigningKeyVerifyCounterService } from 'src/engine/core-modules/jwt/services/signing-key-verify-counter.service';
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -44,7 +45,12 @@ const InternalJwtModule = NestJwtModule.registerAsync({
JwtWrapperService,
JwtKeyManagerService,
SigningKeyEntityCacheProviderService,
SigningKeyVerifyCounterService,
],
exports: [
JwtWrapperService,
JwtKeyManagerService,
SigningKeyVerifyCounterService,
],
exports: [JwtWrapperService, JwtKeyManagerService],
})
export class JwtModule {}
@@ -21,6 +21,7 @@ export type CurrentSigningKey = {
};
const UNIQUE_VIOLATION_PG_CODE = '23505';
const CURRENT_SIGNING_KEY_LOCAL_TTL_MS = 60 * 1000;
@Injectable()
export class JwtKeyManagerService {
@@ -28,6 +29,7 @@ export class JwtKeyManagerService {
private currentSigningKeyPromise: Promise<CurrentSigningKey | null> | null =
null;
private currentSigningKeyCachedAt = 0;
constructor(
@InjectRepository(SigningKeyEntity)
@@ -37,8 +39,13 @@ export class JwtKeyManagerService {
) {}
async getCurrentSigningKey(): Promise<CurrentSigningKey | null> {
if (this.currentSigningKeyPromise === null) {
const isLocalCacheExpired =
Date.now() - this.currentSigningKeyCachedAt >
CURRENT_SIGNING_KEY_LOCAL_TTL_MS;
if (!isDefined(this.currentSigningKeyPromise) || isLocalCacheExpired) {
this.currentSigningKeyPromise = this.loadOrCreateCurrentSigningKey();
this.currentSigningKeyCachedAt = Date.now();
}
try {
@@ -46,11 +53,13 @@ export class JwtKeyManagerService {
if (!isDefined(result)) {
this.currentSigningKeyPromise = null;
this.currentSigningKeyCachedAt = 0;
}
return result;
} catch (error) {
this.currentSigningKeyPromise = null;
this.currentSigningKeyCachedAt = 0;
throw error;
}
}
@@ -63,6 +72,47 @@ export class JwtKeyManagerService {
return this.coreEntityCacheService.get('signingKeyPublicKey', id);
}
async listSigningKeys(): Promise<SigningKeyEntity[]> {
return this.signingKeyRepository.find({
order: { createdAt: 'DESC' },
});
}
async revokeSigningKey(id: string): Promise<SigningKeyEntity> {
if (!isNonEmptyString(id) || !isValidUuid(id)) {
throw new JwtKeyManagerException(
`Invalid signing key id: ${id}`,
JwtKeyManagerExceptionCode.SIGNING_KEY_NOT_FOUND,
);
}
const existing = await this.signingKeyRepository.findOne({ where: { id } });
if (!isDefined(existing)) {
throw new JwtKeyManagerException(
`Signing key not found: ${id}`,
JwtKeyManagerExceptionCode.SIGNING_KEY_NOT_FOUND,
);
}
if (!isDefined(existing.revokedAt)) {
await this.signingKeyRepository.update(
{ id },
{
revokedAt: new Date(),
isCurrent: false,
privateKey: null,
},
);
}
await this.coreEntityCacheService.invalidate('signingKeyPublicKey', id);
this.currentSigningKeyPromise = null;
this.currentSigningKeyCachedAt = 0;
return this.signingKeyRepository.findOneByOrFail({ id });
}
private async loadOrCreateCurrentSigningKey(): Promise<CurrentSigningKey | null> {
try {
const existing = await this.findCurrentSigningKeyRow();
@@ -21,6 +21,7 @@ import {
JWT_LEGACY_ALGORITHM,
} from 'src/engine/core-modules/jwt/constants/jwt-algorithm.constant';
import { JwtKeyManagerService } from 'src/engine/core-modules/jwt/services/jwt-key-manager.service';
import { SigningKeyVerifyCounterService } from 'src/engine/core-modules/jwt/services/signing-key-verify-counter.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { decodeJwtHeader } from 'src/engine/core-modules/jwt/utils/decode-jwt-header.util';
import { decodeJwtPayload } from 'src/engine/core-modules/jwt/utils/decode-jwt-payload.util';
@@ -45,6 +46,7 @@ export class JwtWrapperService {
private readonly jwtService: JwtService,
private readonly twentyConfigService: TwentyConfigService,
private readonly jwtKeyManagerService: JwtKeyManagerService,
private readonly signingKeyVerifyCounterService: SigningKeyVerifyCounterService,
) {}
async signAsyncOrThrow(
@@ -131,6 +133,7 @@ export class JwtWrapperService {
options?: JwtVerifyOptions,
// oxlint-disable-next-line @typescripttypescript/no-explicit-any
): Promise<any> {
const header = decodeJwtHeader(token);
const payload = this.decode<JwtPayload>(token, { json: true });
if (!isDefined(payload)) {
@@ -140,7 +143,14 @@ export class JwtWrapperService {
const { key, algorithm } = await this.resolveVerificationKey(token);
try {
return jwt.verify(token, key, { ...options, algorithms: [algorithm] });
const verified = jwt.verify(token, key, {
...options,
algorithms: [algorithm],
});
this.recordVerifyForAlgorithm(algorithm, header);
return verified;
} catch (error) {
// API_KEY tokens created before 12/12/2025 were accidentally signed
// with ACCESS type instead of API_KEY. Fall back to the legacy ACCESS
@@ -154,11 +164,15 @@ export class JwtWrapperService {
if (isDefined(appSecretBody)) {
try {
return jwt.verify(
const verified = jwt.verify(
token,
this.generateAppSecret(JwtTokenTypeEnum.ACCESS, appSecretBody),
{ ...options, algorithms: [JWT_LEGACY_ALGORITHM] },
);
this.recordVerifyForAlgorithm(JWT_LEGACY_ALGORITHM, header);
return verified;
} catch {
throw this.toAuthException(error);
}
@@ -185,6 +199,22 @@ export class JwtWrapperService {
return ExtractJwt.fromAuthHeaderAsBearerToken();
}
private recordVerifyForAlgorithm(
algorithm: ResolvedVerificationKey['algorithm'],
header: ReturnType<typeof decodeJwtHeader>,
): void {
if (
algorithm === JWT_ASYMMETRIC_ALGORITHM &&
isAsymmetricJwtHeader(header)
) {
this.signingKeyVerifyCounterService.recordKidVerify(header.kid);
return;
}
this.signingKeyVerifyCounterService.recordLegacyVerify();
}
private extractAppSecretBody(payload: JwtPayload): string | undefined {
const workspaceParse = APP_SECRET_BODY_WORKSPACE_SCHEMA.safeParse(payload);
@@ -0,0 +1,183 @@
import {
Injectable,
Logger,
type OnModuleDestroy,
type OnModuleInit,
} from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
const WINDOW_DAYS = 7;
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
const BUCKET_TTL_MS = (WINDOW_DAYS + 1) * ONE_DAY_MS;
const FLUSH_INTERVAL_MS = 30 * 1000;
const REDIS_KEY_PREFIX = 'signing-key-verifies';
const LEGACY_BUCKET_ID = 'legacy';
export type SigningKeyUsage = {
byKid: Record<string, number>;
legacyCount: number;
windowDays: number;
};
@Injectable()
export class SigningKeyVerifyCounterService
implements OnModuleInit, OnModuleDestroy
{
private readonly logger = new Logger(SigningKeyVerifyCounterService.name);
private pendingCounts = new Map<string, number>();
private flushIntervalHandle: NodeJS.Timeout | null = null;
constructor(
@InjectCacheStorage(CacheStorageNamespace.EngineMetrics)
private readonly cacheStorage: CacheStorageService,
) {}
onModuleInit() {
this.flushIntervalHandle = setInterval(() => {
void this.flush();
}, FLUSH_INTERVAL_MS);
if (isDefined(this.flushIntervalHandle.unref)) {
this.flushIntervalHandle.unref();
}
}
async onModuleDestroy() {
if (isDefined(this.flushIntervalHandle)) {
clearInterval(this.flushIntervalHandle);
this.flushIntervalHandle = null;
}
await this.flush();
}
recordKidVerify(kid: string): void {
this.increment(kid);
}
recordLegacyVerify(): void {
this.increment(LEGACY_BUCKET_ID);
}
async getUsageInWindow(kids: string[]): Promise<SigningKeyUsage> {
await this.flush();
const bucketIds = [...kids, LEGACY_BUCKET_ID];
const keysByBucket = bucketIds.map((bucketId) =>
this.buildBucketKeysInWindow(bucketId),
);
let valuesByBucket: (number | undefined)[][];
try {
const flatValues = await this.cacheStorage.mget<number>(
keysByBucket.flat(),
);
valuesByBucket = bucketIds.map((_, bucketIndex) =>
flatValues.slice(
bucketIndex * WINDOW_DAYS,
(bucketIndex + 1) * WINDOW_DAYS,
),
);
} catch (error) {
this.logger.warn(
`Failed to read signing key verify counts: ${
error instanceof Error ? error.message : String(error)
}`,
);
valuesByBucket = bucketIds.map(() => []);
}
const sumWindow = (windowValues: (number | undefined)[]): number =>
windowValues.reduce<number>(
(total, value) =>
isDefined(value) && Number.isFinite(value) ? total + value : total,
0,
);
return {
byKid: Object.fromEntries(
kids.map((kid, kidIndex) => [kid, sumWindow(valuesByBucket[kidIndex])]),
),
legacyCount: sumWindow(valuesByBucket[bucketIds.length - 1]),
windowDays: WINDOW_DAYS,
};
}
private increment(bucketId: string): void {
const key = this.buildBucketKey(bucketId, Date.now());
this.pendingCounts.set(key, (this.pendingCounts.get(key) ?? 0) + 1);
}
private async flush(): Promise<void> {
if (this.pendingCounts.size === 0) {
return;
}
const snapshot = this.pendingCounts;
this.pendingCounts = new Map();
const entries = Array.from(snapshot.entries());
const incrResults = await Promise.allSettled(
entries.map(([key, increment]) =>
this.cacheStorage.incrBy(key, increment),
),
);
const incrementedKeys: string[] = [];
let failedCount = 0;
for (let index = 0; index < entries.length; index++) {
const [key, increment] = entries[index];
if (incrResults[index].status === 'rejected') {
this.pendingCounts.set(
key,
(this.pendingCounts.get(key) ?? 0) + increment,
);
failedCount++;
continue;
}
incrementedKeys.push(key);
}
await Promise.allSettled(
incrementedKeys.map((key) =>
this.cacheStorage.expire(key, BUCKET_TTL_MS),
),
);
if (failedCount > 0) {
this.logger.warn(
`Failed to flush ${failedCount}/${entries.length} signing key verify bucket(s); re-buffered for next flush`,
);
}
}
private buildBucketKey(bucketId: string, timestamp: number): string {
const bucketStart = Math.floor(timestamp / ONE_DAY_MS) * ONE_DAY_MS;
return `${REDIS_KEY_PREFIX}:${bucketId}:${bucketStart}`;
}
private buildBucketKeysInWindow(bucketId: string): string[] {
const currentBucketStart = Math.floor(Date.now() / ONE_DAY_MS) * ONE_DAY_MS;
return Array.from(
{ length: WINDOW_DAYS },
(_, index) =>
`${REDIS_KEY_PREFIX}:${bucketId}:${currentBucketStart - index * ONE_DAY_MS}`,
);
}
}