Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions src/services/salt-store/FirestoreSaltStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export class FirestoreSaltStore implements ISaltStore {
private firestore: Firestore;
private collectionName: string;
private static readonly CLEANUP_BATCH_SIZE = 500;
private static readonly EXPIRE_AT_BUFFER_DAYS = 2;
Comment thread
cmraible marked this conversation as resolved.

/**
* Creates a new FirestoreSaltStore instance.
Expand Down Expand Up @@ -67,6 +68,31 @@ export class FirestoreSaltStore implements ISaltStore {
return Math.min(normalized, FirestoreSaltStore.CLEANUP_BATCH_SIZE);
}

/**
* Computes the expires_at timestamp for a salt document.
* Parses the date from the key (format: salt:{date}:{siteUuid}) and returns
* that date + 2 days at midnight UTC, providing a 24-hour safety buffer.
*
* Falls back to fallbackDate + 2 days if the key format is unexpected.
*/
private getExpireAt(key: string, fallbackDate: Date) {
const parts = key.split(':');
// Expected format: salt:{YYYY-MM-DD}:{siteUuid}
if (parts.length >= 3 && parts[0] === 'salt') {
const dateStr = parts[1];
const parsed = new Date(dateStr);
if (!isNaN(parsed.getTime())) {
const expireAt = new Date(parsed);
expireAt.setUTCDate(expireAt.getUTCDate() + FirestoreSaltStore.EXPIRE_AT_BUFFER_DAYS);
return expireAt;
}
}
// Fallback: created_at + 2 days
const expireAt = new Date(fallbackDate);
expireAt.setUTCDate(expireAt.getUTCDate() + FirestoreSaltStore.EXPIRE_AT_BUFFER_DAYS);
return expireAt;
}

/**
* Performs a basic health check to verify Firestore connectivity.
* This helps fail fast during initialization if Firestore is unavailable.
Expand Down Expand Up @@ -167,8 +193,11 @@ export class FirestoreSaltStore implements ISaltStore {
created_at: now
};

// Use create() for atomic operation - fails if document exists
await docRef.create(record);
// Write expires_at alongside the record for Firestore TTL support
await docRef.create({
...record,
expires_at: this.getExpireAt(key, now)
});

return {
salt,
Expand Down
2 changes: 2 additions & 0 deletions src/services/salt-store/ISaltStore.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export type SaltKey = `salt:${number}-${number}-${number}:${string}`;

export interface SaltRecord {
salt: string;
created_at: Date;
Expand Down
2 changes: 1 addition & 1 deletion src/services/salt-store/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type {ISaltStore, SaltRecord} from './ISaltStore';
export type {ISaltStore, SaltKey, SaltRecord} from './ISaltStore';
export {MemorySaltStore} from './MemorySaltStore';
export {FirestoreSaltStore} from './FirestoreSaltStore';
export {createSaltStore} from './SaltStoreFactory';
Expand Down
6 changes: 3 additions & 3 deletions src/services/user-signature/UserSignatureService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {ISaltStore} from '../salt-store';
import {ISaltStore, SaltKey} from '../salt-store';
import crypto from 'crypto';
import logger from '../../utils/logger';

Expand Down Expand Up @@ -74,9 +74,9 @@ export class UserSignatureService {
* @param siteUuid - The site_uuid to get the salt for
* @returns The key to use to store the salt for the site
*/
private getKey(siteUuid: string): string {
private getKey(siteUuid: string): SaltKey {
const date = new Date().toISOString().split('T')[0];
return `salt:${date}:${siteUuid}`;
return `salt:${date}:${siteUuid}` as SaltKey;
}

/**
Expand Down
72 changes: 72 additions & 0 deletions test/integration/services/salt-store/FirestoreSaltStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,4 +511,76 @@ describe('FirestoreSaltStore', () => {
expect(Math.abs(stored!.created_at.getTime() - result.created_at.getTime())).toBeLessThan(1000);
});
});

describe('expires_at', () => {
it('should write expires_at when creating a document via set()', async () => {
const key = 'salt:2024-01-15:550e8400-e29b-41d4-a716-446655440000';
await saltStore.set(key, 'test-salt');

// Read the raw Firestore document to check expires_at
const firestore = (saltStore as any).firestore;
const doc = await firestore.collection(testCollectionName).doc(key).get();
const data = doc.data();

expect(data.expires_at).toBeDefined();

// Key date is 2024-01-15, expires_at should be 2024-01-17T00:00:00.000Z (key date + 2 days)
const expireAt = data.expires_at.toDate();
expect(expireAt).toEqual(new Date('2024-01-17T00:00:00.000Z'));
});

it('should write expires_at when creating a document via getOrCreate()', async () => {
const key = 'salt:2024-03-20:550e8400-e29b-41d4-a716-446655440000';
await saltStore.getOrCreate(key, () => 'test-salt');

const firestore = (saltStore as any).firestore;
const doc = await firestore.collection(testCollectionName).doc(key).get();
const data = doc.data();

expect(data.expires_at).toBeDefined();
const expireAt = data.expires_at.toDate();
expect(expireAt).toEqual(new Date('2024-03-22T00:00:00.000Z'));
});

it('should fall back to created_at + 2 days when key format is unexpected', async () => {
const key = 'unexpected-key-format';
await saltStore.set(key, 'test-salt');

const firestore = (saltStore as any).firestore;
const doc = await firestore.collection(testCollectionName).doc(key).get();
const data = doc.data();

expect(data.expires_at).toBeDefined();
const expireAt = data.expires_at.toDate();
const createdAt = data.created_at.toDate();

// expires_at should be created_at + 2 days
const expectedExpireAt = new Date(createdAt);
expectedExpireAt.setUTCDate(expectedExpireAt.getUTCDate() + 2);

// Compare to within 1 second to account for execution time
expect(Math.abs(expireAt.getTime() - expectedExpireAt.getTime())).toBeLessThan(1000);
});

it('should not include expires_at in SaltRecord returned by get()', async () => {
const key = 'salt:2024-01-15:550e8400-e29b-41d4-a716-446655440000';
await saltStore.set(key, 'test-salt');

const record = await saltStore.get(key);
expect(record).toBeDefined();
expect(record!.salt).toBe('test-salt');
expect(record!.created_at).toBeInstanceOf(Date);
expect((record as any).expires_at).toBeUndefined();
});

it('should not include expires_at in SaltRecords returned by getAll()', async () => {
await saltStore.set('salt:2024-01-15:550e8400-e29b-41d4-a716-446655440000', 'salt-1');
await saltStore.set('salt:2024-01-16:550e8400-e29b-41d4-a716-446655440000', 'salt-2');

const records = await saltStore.getAll();
for (const record of Object.values(records)) {
expect((record as any).expires_at).toBeUndefined();
}
});
});
});