Skip to content

Commit 5b9d003

Browse files
authored
Add expires_at timestamp to Firestore salt documents (#602)
closes https://linear.app/ghost/issue/NY-1083/add-an-expire-at-timestamp-to-future-firestore-keys ## Summary - Adds an `expires_at` field to new Firestore salt documents, computed as the key's date + 2 days at midnight UTC - This is a stepping stone toward using Firestore's native TTL deletion, which will eventually replace the manual cleanup scheduler added in #582 - `expire_at` is a Firestore-only storage concern — not added to `SaltRecord` or `ISaltStore` ## Changes - **`FirestoreSaltStore.ts`**: Added private `getExpireAt(key, fallbackDate)` helper that parses the date from the key format `salt:{date}:{siteUuid}` and returns that date + 2 days. Falls back to `fallbackDate + 2 days` for unexpected key formats. Modified `set()` to include `expires_at` when writing documents. - **`FirestoreSaltStore.test.ts`**: Added 6 integration tests covering `set()`, `getOrCreate()`, fallback behavior, and verifying `get()`/`getAll()` don't leak `expires_at` into `SaltRecord`. ## Test plan - [x] `expires_at` written correctly via `set()` (key date + 2 days) - [x] `expires_at` written correctly via `getOrCreate()` - [x] Fallback to `created_at + 2 days` for unexpected key formats - [x] `get()` does not include `expires_at` in returned `SaltRecord` - [x] `getAll()` does not include `expires_at` in returned records - [x] All 414 unit tests pass - [x] All 108 integration tests pass - [x] Lint clean
1 parent dd7d3c5 commit 5b9d003

5 files changed

Lines changed: 109 additions & 6 deletions

File tree

src/services/salt-store/FirestoreSaltStore.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export class FirestoreSaltStore implements ISaltStore {
2525
private firestore: Firestore;
2626
private collectionName: string;
2727
private static readonly CLEANUP_BATCH_SIZE = 500;
28+
private static readonly EXPIRE_AT_BUFFER_DAYS = 2;
2829

2930
/**
3031
* Creates a new FirestoreSaltStore instance.
@@ -67,6 +68,31 @@ export class FirestoreSaltStore implements ISaltStore {
6768
return Math.min(normalized, FirestoreSaltStore.CLEANUP_BATCH_SIZE);
6869
}
6970

71+
/**
72+
* Computes the expires_at timestamp for a salt document.
73+
* Parses the date from the key (format: salt:{date}:{siteUuid}) and returns
74+
* that date + 2 days at midnight UTC, providing a 24-hour safety buffer.
75+
*
76+
* Falls back to fallbackDate + 2 days if the key format is unexpected.
77+
*/
78+
private getExpireAt(key: string, fallbackDate: Date) {
79+
const parts = key.split(':');
80+
// Expected format: salt:{YYYY-MM-DD}:{siteUuid}
81+
if (parts.length >= 3 && parts[0] === 'salt') {
82+
const dateStr = parts[1];
83+
const parsed = new Date(dateStr);
84+
if (!isNaN(parsed.getTime())) {
85+
const expireAt = new Date(parsed);
86+
expireAt.setUTCDate(expireAt.getUTCDate() + FirestoreSaltStore.EXPIRE_AT_BUFFER_DAYS);
87+
return expireAt;
88+
}
89+
}
90+
// Fallback: created_at + 2 days
91+
const expireAt = new Date(fallbackDate);
92+
expireAt.setUTCDate(expireAt.getUTCDate() + FirestoreSaltStore.EXPIRE_AT_BUFFER_DAYS);
93+
return expireAt;
94+
}
95+
7096
/**
7197
* Performs a basic health check to verify Firestore connectivity.
7298
* This helps fail fast during initialization if Firestore is unavailable.
@@ -167,8 +193,11 @@ export class FirestoreSaltStore implements ISaltStore {
167193
created_at: now
168194
};
169195

170-
// Use create() for atomic operation - fails if document exists
171-
await docRef.create(record);
196+
// Write expires_at alongside the record for Firestore TTL support
197+
await docRef.create({
198+
...record,
199+
expires_at: this.getExpireAt(key, now)
200+
});
172201

173202
return {
174203
salt,

src/services/salt-store/ISaltStore.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
export type SaltKey = `salt:${number}-${number}-${number}:${string}`;
2+
13
export interface SaltRecord {
24
salt: string;
35
created_at: Date;

src/services/salt-store/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export type {ISaltStore, SaltRecord} from './ISaltStore';
1+
export type {ISaltStore, SaltKey, SaltRecord} from './ISaltStore';
22
export {MemorySaltStore} from './MemorySaltStore';
33
export {FirestoreSaltStore} from './FirestoreSaltStore';
44
export {createSaltStore} from './SaltStoreFactory';

src/services/user-signature/UserSignatureService.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {ISaltStore} from '../salt-store';
1+
import {ISaltStore, SaltKey} from '../salt-store';
22
import crypto from 'crypto';
33
import logger from '../../utils/logger';
44

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

8282
/**

test/integration/services/salt-store/FirestoreSaltStore.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,4 +511,76 @@ describe('FirestoreSaltStore', () => {
511511
expect(Math.abs(stored!.created_at.getTime() - result.created_at.getTime())).toBeLessThan(1000);
512512
});
513513
});
514+
515+
describe('expires_at', () => {
516+
it('should write expires_at when creating a document via set()', async () => {
517+
const key = 'salt:2024-01-15:550e8400-e29b-41d4-a716-446655440000';
518+
await saltStore.set(key, 'test-salt');
519+
520+
// Read the raw Firestore document to check expires_at
521+
const firestore = (saltStore as any).firestore;
522+
const doc = await firestore.collection(testCollectionName).doc(key).get();
523+
const data = doc.data();
524+
525+
expect(data.expires_at).toBeDefined();
526+
527+
// Key date is 2024-01-15, expires_at should be 2024-01-17T00:00:00.000Z (key date + 2 days)
528+
const expireAt = data.expires_at.toDate();
529+
expect(expireAt).toEqual(new Date('2024-01-17T00:00:00.000Z'));
530+
});
531+
532+
it('should write expires_at when creating a document via getOrCreate()', async () => {
533+
const key = 'salt:2024-03-20:550e8400-e29b-41d4-a716-446655440000';
534+
await saltStore.getOrCreate(key, () => 'test-salt');
535+
536+
const firestore = (saltStore as any).firestore;
537+
const doc = await firestore.collection(testCollectionName).doc(key).get();
538+
const data = doc.data();
539+
540+
expect(data.expires_at).toBeDefined();
541+
const expireAt = data.expires_at.toDate();
542+
expect(expireAt).toEqual(new Date('2024-03-22T00:00:00.000Z'));
543+
});
544+
545+
it('should fall back to created_at + 2 days when key format is unexpected', async () => {
546+
const key = 'unexpected-key-format';
547+
await saltStore.set(key, 'test-salt');
548+
549+
const firestore = (saltStore as any).firestore;
550+
const doc = await firestore.collection(testCollectionName).doc(key).get();
551+
const data = doc.data();
552+
553+
expect(data.expires_at).toBeDefined();
554+
const expireAt = data.expires_at.toDate();
555+
const createdAt = data.created_at.toDate();
556+
557+
// expires_at should be created_at + 2 days
558+
const expectedExpireAt = new Date(createdAt);
559+
expectedExpireAt.setUTCDate(expectedExpireAt.getUTCDate() + 2);
560+
561+
// Compare to within 1 second to account for execution time
562+
expect(Math.abs(expireAt.getTime() - expectedExpireAt.getTime())).toBeLessThan(1000);
563+
});
564+
565+
it('should not include expires_at in SaltRecord returned by get()', async () => {
566+
const key = 'salt:2024-01-15:550e8400-e29b-41d4-a716-446655440000';
567+
await saltStore.set(key, 'test-salt');
568+
569+
const record = await saltStore.get(key);
570+
expect(record).toBeDefined();
571+
expect(record!.salt).toBe('test-salt');
572+
expect(record!.created_at).toBeInstanceOf(Date);
573+
expect((record as any).expires_at).toBeUndefined();
574+
});
575+
576+
it('should not include expires_at in SaltRecords returned by getAll()', async () => {
577+
await saltStore.set('salt:2024-01-15:550e8400-e29b-41d4-a716-446655440000', 'salt-1');
578+
await saltStore.set('salt:2024-01-16:550e8400-e29b-41d4-a716-446655440000', 'salt-2');
579+
580+
const records = await saltStore.getAll();
581+
for (const record of Object.values(records)) {
582+
expect((record as any).expires_at).toBeUndefined();
583+
}
584+
});
585+
});
514586
});

0 commit comments

Comments
 (0)