Skip to content

Commit 8ed20be

Browse files
authored
Merge pull request #60 from MoscowDev/feature/add-access-decision
add access decision task implemented
2 parents a24e7a9 + b823d4e commit 8ed20be

6 files changed

Lines changed: 493 additions & 27 deletions

File tree

apps/access-api/jest.config.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
module.exports = {
2-
preset: 'ts-jest',
32
testEnvironment: 'node',
43
roots: ['<rootDir>/test', '<rootDir>/src'],
54
testMatch: ['**/*.test.ts'],
5+
6+
// Ensure TypeScript files are transformed for Jest.
7+
// Prefer ts-jest when available; otherwise, this config will surface a clear error.
8+
transform: {
9+
'^.+\\.tsx?$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.json', isolatedModules: true }],
10+
},
611
};
12+
13+
14+

apps/access-api/src/config.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,26 @@ const ConfigSchema = z.object({
2323
.enum(['error', 'warn', 'info', 'debug'])
2424
.default('info'),
2525

26+
// Access decision caching (disabled by default)
27+
accessDecisionCacheEnabled: z
28+
.coerce
29+
.boolean()
30+
.default(false),
31+
accessDecisionCacheTtlSeconds: z
32+
.coerce
33+
.number()
34+
.int()
35+
.positive('accessDecisionCacheTtlSeconds must be > 0')
36+
.default(30),
37+
// TTL for version counters; prevents unbounded key growth if never updated
38+
accessDecisionCacheVersionTtlSeconds: z
39+
.coerce
40+
.number()
41+
.int()
42+
.positive('accessDecisionCacheVersionTtlSeconds must be > 0')
43+
.default(86400),
44+
// Redis connection (required only when accessDecisionCacheEnabled=true)
45+
redisUrl: z.string().optional(),
2646
// Reconciliation worker
2747
reconciliationIntervalMs: z.coerce
2848
.number()
@@ -70,6 +90,11 @@ function validateConfig(): Config {
7090
console.log(` LOG_LEVEL: ${result.data.logLevel}\n`);
7191
}
7292

93+
// If caching is enabled, ensure redisUrl is present.
94+
if (result.data.accessDecisionCacheEnabled && !result.data.redisUrl) {
95+
throw new Error('accessDecisionCacheEnabled=true requires redisUrl');
96+
}
97+
7398
return result.data;
7499
}
75100

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { InMemoryCacheService } from './cacheService';
2+
3+
// Note: This file is only intended for local verification of cache
4+
// primitives. The repo's Jest runner config currently points at
5+
// apps/access-api/test, so these tests may be skipped in CI.
6+
7+
describe('Access decision cache primitives', () => {
8+
test('in-memory cache hit/miss and TTL expiry', async () => {
9+
const cache = new InMemoryCacheService();
10+
const key = 'k1';
11+
12+
expect(await cache.getJSON<number>(key)).toBeNull();
13+
14+
await cache.setJSON(key, 123, 1); // 1s TTL
15+
expect((await cache.getJSON<number>(key))?.value).toBe(123);
16+
17+
// Wait slightly longer than TTL
18+
await new Promise((r) => setTimeout(r, 1100));
19+
expect(await cache.getJSON<number>(key)).toBeNull();
20+
});
21+
22+
test('incr/getIncr increments with eviction when TTL expires', async () => {
23+
const cache = new InMemoryCacheService();
24+
const key = 'ver';
25+
26+
expect(await cache.getIncr(key)).toBeNull();
27+
28+
const v1 = await cache.incr(key, 1);
29+
expect(v1).toBe(1);
30+
31+
expect(await cache.getIncr(key)).toBe(1);
32+
33+
await new Promise((r) => setTimeout(r, 1100));
34+
expect(await cache.getIncr(key)).toBeNull();
35+
});
36+
});
37+
38+
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
export type CacheGetResult<T> = { value: T } | null;
2+
3+
export interface CacheService {
4+
getJSON<T>(key: string): Promise<CacheGetResult<T>>;
5+
setJSON<T>(key: string, value: T, ttlSeconds: number): Promise<void>;
6+
del(key: string): Promise<void>;
7+
/**
8+
* Atomically increments a version counter stored at `key`.
9+
*
10+
* Implementations must return the new value.
11+
*/
12+
incr(key: string, ttlSeconds?: number): Promise<number>;
13+
/**
14+
* Returns current integer value for `key` or null if missing.
15+
*/
16+
getIncr(key: string): Promise<number | null>;
17+
}
18+
19+
export class NoopCacheService implements CacheService {
20+
async getJSON<T>(_key: string): Promise<CacheGetResult<T>> {
21+
return null;
22+
}
23+
async setJSON<T>(_key: string, _value: T, _ttlSeconds: number): Promise<void> {
24+
return;
25+
}
26+
async del(_key: string): Promise<void> {
27+
return;
28+
}
29+
async incr(_key: string, _ttlSeconds?: number): Promise<number> {
30+
return 1;
31+
}
32+
async getIncr(_key: string): Promise<number | null> {
33+
return null;
34+
}
35+
}
36+
37+
/**
38+
* Simple in-memory cache for unit tests.
39+
*/
40+
export class InMemoryCacheService implements CacheService {
41+
private store = new Map<
42+
string,
43+
{ value: unknown; expiresAt: number | null }
44+
>();
45+
private incrStore = new Map<string, { value: number; expiresAt: number | null }>();
46+
47+
async getJSON<T>(key: string): Promise<CacheGetResult<T>> {
48+
const entry = this.store.get(key);
49+
if (!entry) return null;
50+
if (entry.expiresAt !== null && Date.now() > entry.expiresAt) {
51+
this.store.delete(key);
52+
return null;
53+
}
54+
return { value: entry.value as T };
55+
}
56+
57+
async setJSON<T>(key: string, value: T, ttlSeconds: number): Promise<void> {
58+
const expiresAt = ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null;
59+
this.store.set(key, { value, expiresAt });
60+
}
61+
62+
async del(key: string): Promise<void> {
63+
this.store.delete(key);
64+
this.incrStore.delete(key);
65+
}
66+
67+
async incr(key: string, ttlSeconds?: number): Promise<number> {
68+
const existing = this.incrStore.get(key);
69+
const current = existing ? existing.value : 0;
70+
const next = current + 1;
71+
const expiresAt = ttlSeconds && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null;
72+
this.incrStore.set(key, { value: next, expiresAt });
73+
return next;
74+
}
75+
76+
async getIncr(key: string): Promise<number | null> {
77+
const entry = this.incrStore.get(key);
78+
if (!entry) return null;
79+
if (entry.expiresAt !== null && Date.now() > entry.expiresAt) {
80+
this.incrStore.delete(key);
81+
return null;
82+
}
83+
return entry.value;
84+
}
85+
}
86+

0 commit comments

Comments
 (0)