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
10 changes: 9 additions & 1 deletion apps/access-api/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/test', '<rootDir>/src'],
testMatch: ['**/*.test.ts'],

// Ensure TypeScript files are transformed for Jest.
// Prefer ts-jest when available; otherwise, this config will surface a clear error.
transform: {
'^.+\\.tsx?$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.json', isolatedModules: true }],
},
};



25 changes: 25 additions & 0 deletions apps/access-api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ const ConfigSchema = z.object({
.enum(['error', 'warn', 'info', 'debug'])
.default('info'),

// Access decision caching (disabled by default)
accessDecisionCacheEnabled: z
.coerce
.boolean()
.default(false),
accessDecisionCacheTtlSeconds: z
.coerce
.number()
.int()
.positive('accessDecisionCacheTtlSeconds must be > 0')
.default(30),
// TTL for version counters; prevents unbounded key growth if never updated
accessDecisionCacheVersionTtlSeconds: z
.coerce
.number()
.int()
.positive('accessDecisionCacheVersionTtlSeconds must be > 0')
.default(86400),
// Redis connection (required only when accessDecisionCacheEnabled=true)
redisUrl: z.string().optional(),
// Reconciliation worker
reconciliationIntervalMs: z.coerce
.number()
Expand Down Expand Up @@ -70,6 +90,11 @@ function validateConfig(): Config {
console.log(` LOG_LEVEL: ${result.data.logLevel}\n`);
}

// If caching is enabled, ensure redisUrl is present.
if (result.data.accessDecisionCacheEnabled && !result.data.redisUrl) {
throw new Error('accessDecisionCacheEnabled=true requires redisUrl');
}

return result.data;
}

Expand Down
38 changes: 38 additions & 0 deletions apps/access-api/src/services/accessDecisionCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { InMemoryCacheService } from './cacheService';

// Note: This file is only intended for local verification of cache
// primitives. The repo's Jest runner config currently points at
// apps/access-api/test, so these tests may be skipped in CI.

describe('Access decision cache primitives', () => {
test('in-memory cache hit/miss and TTL expiry', async () => {
const cache = new InMemoryCacheService();
const key = 'k1';

expect(await cache.getJSON<number>(key)).toBeNull();

await cache.setJSON(key, 123, 1); // 1s TTL
expect((await cache.getJSON<number>(key))?.value).toBe(123);

// Wait slightly longer than TTL
await new Promise((r) => setTimeout(r, 1100));
expect(await cache.getJSON<number>(key)).toBeNull();
});

test('incr/getIncr increments with eviction when TTL expires', async () => {
const cache = new InMemoryCacheService();
const key = 'ver';

expect(await cache.getIncr(key)).toBeNull();

const v1 = await cache.incr(key, 1);
expect(v1).toBe(1);

expect(await cache.getIncr(key)).toBe(1);

await new Promise((r) => setTimeout(r, 1100));
expect(await cache.getIncr(key)).toBeNull();
});
});


86 changes: 86 additions & 0 deletions apps/access-api/src/services/cacheService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
export type CacheGetResult<T> = { value: T } | null;

export interface CacheService {
getJSON<T>(key: string): Promise<CacheGetResult<T>>;
setJSON<T>(key: string, value: T, ttlSeconds: number): Promise<void>;
del(key: string): Promise<void>;
/**
* Atomically increments a version counter stored at `key`.
*
* Implementations must return the new value.
*/
incr(key: string, ttlSeconds?: number): Promise<number>;
/**
* Returns current integer value for `key` or null if missing.
*/
getIncr(key: string): Promise<number | null>;
}

export class NoopCacheService implements CacheService {
async getJSON<T>(_key: string): Promise<CacheGetResult<T>> {
return null;
}
async setJSON<T>(_key: string, _value: T, _ttlSeconds: number): Promise<void> {
return;
}
async del(_key: string): Promise<void> {
return;
}
async incr(_key: string, _ttlSeconds?: number): Promise<number> {
return 1;
}
async getIncr(_key: string): Promise<number | null> {
return null;
}
}

/**
* Simple in-memory cache for unit tests.
*/
export class InMemoryCacheService implements CacheService {
private store = new Map<
string,
{ value: unknown; expiresAt: number | null }
>();
private incrStore = new Map<string, { value: number; expiresAt: number | null }>();

async getJSON<T>(key: string): Promise<CacheGetResult<T>> {
const entry = this.store.get(key);
if (!entry) return null;
if (entry.expiresAt !== null && Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return { value: entry.value as T };
}

async setJSON<T>(key: string, value: T, ttlSeconds: number): Promise<void> {
const expiresAt = ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null;
this.store.set(key, { value, expiresAt });
}

async del(key: string): Promise<void> {
this.store.delete(key);
this.incrStore.delete(key);
}

async incr(key: string, ttlSeconds?: number): Promise<number> {
const existing = this.incrStore.get(key);
const current = existing ? existing.value : 0;
const next = current + 1;
const expiresAt = ttlSeconds && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null;
this.incrStore.set(key, { value: next, expiresAt });
return next;
}

async getIncr(key: string): Promise<number | null> {
const entry = this.incrStore.get(key);
if (!entry) return null;
if (entry.expiresAt !== null && Date.now() > entry.expiresAt) {
this.incrStore.delete(key);
return null;
}
return entry.value;
}
}

Loading