From 5b0f1f069939aa714929f0de66bf2e18bf73b97c Mon Sep 17 00:00:00 2001 From: KeeganBeuthin Date: Thu, 26 Jun 2025 09:49:14 +0200 Subject: [PATCH 1/2] kv-storage --- lib/main.ts | 1 + lib/sessionManager/index.ts | 1 + lib/sessionManager/stores/kvStorage.test.ts | 224 ++++++++++++++++++++ lib/sessionManager/stores/kvStorage.ts | 171 +++++++++++++++ 4 files changed, 397 insertions(+) create mode 100644 lib/sessionManager/stores/kvStorage.test.ts create mode 100644 lib/sessionManager/stores/kvStorage.ts diff --git a/lib/main.ts b/lib/main.ts index 3b9c875..711474c 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -57,6 +57,7 @@ export { MemoryStorage, ChromeStore, LocalStorage, + KvStorage, StorageKeys, } from "./sessionManager"; diff --git a/lib/sessionManager/index.ts b/lib/sessionManager/index.ts index 1c96581..1a3a286 100644 --- a/lib/sessionManager/index.ts +++ b/lib/sessionManager/index.ts @@ -25,6 +25,7 @@ export { MemoryStorage } from "./stores/memory.js"; export { ChromeStore } from "./stores/chromeStore.js"; export { ExpoSecureStore } from "./stores/expoSecureStore.js"; export { LocalStorage } from "./stores/localStorage.ts"; +export { KvStorage } from "./stores/kvStorage.ts"; // Export types directly export { StorageKeys } from "./types.ts"; diff --git a/lib/sessionManager/stores/kvStorage.test.ts b/lib/sessionManager/stores/kvStorage.test.ts new file mode 100644 index 0000000..b7a6a31 --- /dev/null +++ b/lib/sessionManager/stores/kvStorage.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { KvStorage } from "./kvStorage"; +import { StorageKeys } from "../types"; +import { storageSettings } from ".."; + +enum ExtraKeys { + testKey = "testKey2", +} + +const createMockKV = () => { + const store: Record = {}; + + return { + async get(key: string): Promise { + return store[key] || null; + }, + async put(key: string, value: string, options?: { expirationTtl?: number }): Promise { + store[key] = value; + }, + async delete(key: string): Promise { + delete store[key]; + }, + async list(options?: { prefix?: string }): Promise<{ keys: Array<{ name: string }> }> { + const keys = Object.keys(store); + const filteredKeys = options?.prefix + ? keys.filter(key => key.startsWith(options.prefix!)) + : keys; + return { + keys: filteredKeys.map(name => ({ name })) + }; + }, + _getStore: () => ({ ...store }), + _clear: () => { + Object.keys(store).forEach(key => delete store[key]); + } + }; +}; + +describe("KvStorage standard keys", () => { + let sessionManager: KvStorage; + let mockKV: ReturnType; + const consoleSpy = vi.spyOn(console, "warn"); + + beforeEach(() => { + mockKV = createMockKV(); + sessionManager = new KvStorage(mockKV); + consoleSpy.mockClear(); + }); + + it("should show warning when using insecure refresh token setting", () => { + storageSettings.useInsecureForRefreshToken = true; + new KvStorage(mockKV); + expect(consoleSpy).toHaveBeenCalledWith( + "KvStorage: useInsecureForRefreshToken is enabled - consider security implications for refresh tokens in KV storage" + ); + storageSettings.useInsecureForRefreshToken = false; + }); + + it("should not show warning when using secure refresh tokens", () => { + storageSettings.useInsecureForRefreshToken = false; + new KvStorage(mockKV); + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + it("should set and get an item in session storage", async () => { + await sessionManager.setSessionItem(StorageKeys.accessToken, "testValue"); + expect(await sessionManager.getSessionItem(StorageKeys.accessToken)).toBe( + "testValue", + ); + }); + + it("should remove an item from session storage", async () => { + await sessionManager.setSessionItem(StorageKeys.accessToken, "testValue"); + expect(await sessionManager.getSessionItem(StorageKeys.accessToken)).toBe( + "testValue", + ); + + await sessionManager.removeSessionItem(StorageKeys.accessToken); + expect( + await sessionManager.getSessionItem(StorageKeys.accessToken), + ).toBeNull(); + }); + + it("should clear all items from session storage", async () => { + await sessionManager.setSessionItem(StorageKeys.accessToken, "testValue"); + expect(await sessionManager.getSessionItem(StorageKeys.accessToken)).toBe( + "testValue", + ); + + await sessionManager.destroySession(); + expect( + await sessionManager.getSessionItem(StorageKeys.accessToken), + ).toBeNull(); + }); + + it("should set many items", async () => { + await sessionManager.setItems({ + [StorageKeys.accessToken]: "accessTokenValue", + [StorageKeys.idToken]: "idTokenValue", + }); + expect(await sessionManager.getSessionItem(StorageKeys.accessToken)).toBe( + "accessTokenValue", + ); + expect(await sessionManager.getSessionItem(StorageKeys.idToken)).toBe( + "idTokenValue", + ); + }); + + it("should handle large strings by chunking", async () => { + const largeString = "x".repeat(storageSettings.maxLength * 2.5); // 5000 chars + await sessionManager.setSessionItem(StorageKeys.accessToken, largeString); + + const store = mockKV._getStore(); + const chunks = Object.keys(store).filter(key => + key.startsWith(`${storageSettings.keyPrefix}${StorageKeys.accessToken}`) + ); + expect(chunks.length).toBeGreaterThan(1); + + expect(await sessionManager.getSessionItem(StorageKeys.accessToken)).toBe( + largeString, + ); + }); + + it("should handle non-string values by casting to string", async () => { + await sessionManager.setSessionItem(StorageKeys.state, true); + expect(await sessionManager.getSessionItem(StorageKeys.state)).toBe("true"); + + await sessionManager.setSessionItem(StorageKeys.state, 42); + expect(await sessionManager.getSessionItem(StorageKeys.state)).toBe("42"); + }); + + it("should use default TTL", () => { + expect(sessionManager.getDefaultTtl()).toBe(3600); + }); + + it("should allow setting custom TTL", () => { + sessionManager.setDefaultTtl(7200); + expect(sessionManager.getDefaultTtl()).toBe(7200); + }); + + it("should create with custom TTL in constructor", () => { + const customManager = new KvStorage(mockKV, { defaultTtl: 1800 }); + expect(customManager.getDefaultTtl()).toBe(1800); + }); +}); + +describe("KvStorage custom keys", () => { + let sessionManager: KvStorage; + let mockKV: ReturnType; + + beforeEach(() => { + mockKV = createMockKV(); + sessionManager = new KvStorage(mockKV); + }); + + it("should set and get an item with custom key type", async () => { + await sessionManager.setSessionItem(ExtraKeys.testKey, "testValue"); + expect(await sessionManager.getSessionItem(ExtraKeys.testKey)).toBe( + "testValue", + ); + }); + + it("should still work with standard StorageKeys", async () => { + await sessionManager.setSessionItem(StorageKeys.accessToken, "testValue"); + expect(await sessionManager.getSessionItem(StorageKeys.accessToken)).toBe( + "testValue", + ); + }); + + it("should clear all items including custom keys", async () => { + await sessionManager.setSessionItem(ExtraKeys.testKey, "testValue"); + await sessionManager.setSessionItem(StorageKeys.accessToken, "tokenValue"); + + await sessionManager.destroySession(); + + expect(await sessionManager.getSessionItem(ExtraKeys.testKey)).toBeNull(); + expect(await sessionManager.getSessionItem(StorageKeys.accessToken)).toBeNull(); + }); +}); + +describe("KvStorage error handling", () => { + let sessionManager: KvStorage; + let mockKV: any; + + beforeEach(() => { + mockKV = { + get: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + list: vi.fn(), + }; + sessionManager = new KvStorage(mockKV); + }); + + it("should handle get errors gracefully", async () => { + mockKV.get.mockRejectedValue(new Error("KV error")); + + const result = await sessionManager.getSessionItem(StorageKeys.accessToken); + expect(result).toBeNull(); + }); + + it("should throw on set errors", async () => { + mockKV.put.mockRejectedValue(new Error("KV error")); + + await expect( + sessionManager.setSessionItem(StorageKeys.accessToken, "value") + ).rejects.toThrow("KV error"); + }); + + it("should throw on remove errors", async () => { + mockKV.get.mockResolvedValue("value"); + mockKV.delete.mockRejectedValue(new Error("KV error")); + + await expect( + sessionManager.removeSessionItem(StorageKeys.accessToken) + ).rejects.toThrow("KV error"); + }); + + it("should throw on destroySession errors", async () => { + mockKV.list.mockRejectedValue(new Error("KV error")); + + await expect(sessionManager.destroySession()).rejects.toThrow("KV error"); + }); +}); \ No newline at end of file diff --git a/lib/sessionManager/stores/kvStorage.ts b/lib/sessionManager/stores/kvStorage.ts new file mode 100644 index 0000000..fb4120f --- /dev/null +++ b/lib/sessionManager/stores/kvStorage.ts @@ -0,0 +1,171 @@ +import { storageSettings } from "../index.js"; +import { SessionBase, StorageKeys, type SessionManager } from "../types.js"; +import { splitString } from "../../utils/splitString.js"; + +interface CloudflareKV { + get(key: string): Promise; + put(key: string, value: string, options?: { expirationTtl?: number }): Promise; + delete(key: string): Promise; + list(options?: { prefix?: string }): Promise<{ keys: Array<{ name: string }> }>; +} + +/** + * Provides a Cloudflare KV based session manager implementation for server-side environments. + * @class KvStorage + */ +export class KvStorage + extends SessionBase + implements SessionManager +{ + private kvNamespace: CloudflareKV; + private defaultTtl: number; + + constructor(kvNamespace: CloudflareKV, options?: { defaultTtl?: number }) { + super(); + this.kvNamespace = kvNamespace; + this.defaultTtl = options?.defaultTtl || 3600; + + if (storageSettings.useInsecureForRefreshToken) { + console.warn("KvStorage: useInsecureForRefreshToken is enabled - consider security implications for refresh tokens in KV storage"); + } + } + + /** + * Clears all items from session store. + * @returns {void} + */ + async destroySession(): Promise { + try { + const { keys } = await this.kvNamespace.list({ + prefix: storageSettings.keyPrefix + }); + + await Promise.all( + keys.map(key => this.kvNamespace.delete(key.name)) + ); + } catch (error) { + console.error('KvStorage: Failed to destroy session:', error); + throw error; + } + } + + /** + * Sets the provided key-value store to the KV storage. + * @param {string} itemKey + * @param {unknown} itemValue + * @returns {void} + */ + async setSessionItem( + itemKey: V | StorageKeys, + itemValue: unknown, + ): Promise { + try { + await this.removeSessionItem(itemKey); + + if (typeof itemValue === "string") { + const chunks = splitString(itemValue, storageSettings.maxLength); + + await Promise.all( + chunks.map((splitValue, index) => + this.kvNamespace.put( + `${storageSettings.keyPrefix}${itemKey}${index}`, + splitValue, + { expirationTtl: this.defaultTtl } + ) + ) + ); + return; + } + + await this.kvNamespace.put( + `${storageSettings.keyPrefix}${String(itemKey)}0`, + itemValue as string, + { expirationTtl: this.defaultTtl } + ); + } catch (error) { + console.error(`KvStorage: Failed to set session item ${String(itemKey)}:`, error); + throw error; + } + } + + /** + * Gets the item for the provided key from the KV storage. + * @param {string} itemKey + * @returns {unknown | null} + */ + async getSessionItem(itemKey: V | StorageKeys): Promise { + try { + const firstChunk = await this.kvNamespace.get( + `${storageSettings.keyPrefix}${String(itemKey)}0` + ); + + if (firstChunk === null) { + return null; + } + + let itemValue = ""; + let index = 0; + let currentChunk: string | null = firstChunk; + + while (currentChunk !== null) { + itemValue += currentChunk; + index++; + + currentChunk = await this.kvNamespace.get( + `${storageSettings.keyPrefix}${String(itemKey)}${index}` + ); + } + + return itemValue; + } catch (error) { + console.error(`KvStorage: Failed to get session item ${String(itemKey)}:`, error); + return null; + } + } + + /** + * Removes the item for the provided key from the KV storage. + * @param {string} itemKey + * @returns {void} + */ + async removeSessionItem(itemKey: V | StorageKeys): Promise { + try { + const keysToDelete: string[] = []; + let index = 0; + + while (true) { + const key = `${storageSettings.keyPrefix}${String(itemKey)}${index}`; + const value = await this.kvNamespace.get(key); + + if (value === null) { + break; + } + + keysToDelete.push(key); + index++; + } + + await Promise.all( + keysToDelete.map(key => this.kvNamespace.delete(key)) + ); + } catch (error) { + console.error(`KvStorage: Failed to remove session item ${String(itemKey)}:`, error); + throw error; + } + } + + /** + * Updates the TTL for stored items (KV-specific method) + * @param ttl - Time to live in seconds + */ + setDefaultTtl(ttl: number): void { + this.defaultTtl = ttl; + } + + /** + * Gets the current default TTL + */ + getDefaultTtl(): number { + return this.defaultTtl; + } +} \ No newline at end of file From 0745a87d2a1034eb813a8fe7f71b0efd8c8172ad Mon Sep 17 00:00:00 2001 From: KeeganBeuthin Date: Fri, 27 Jun 2025 13:29:25 +0200 Subject: [PATCH 2/2] added cookie storage to js-utils. Modified kvStorage to account for consistency --- lib/main.ts | 6 +- lib/sessionManager/index.ts | 4 +- .../stores/cookieStorage.test.ts | 303 ++++++++++++++++++ lib/sessionManager/stores/cookieStorage.ts | 230 +++++++++++++ lib/sessionManager/stores/kvStorage.test.ts | 300 +++++++++++++++-- lib/sessionManager/stores/kvStorage.ts | 159 +++++++-- 6 files changed, 948 insertions(+), 54 deletions(-) create mode 100644 lib/sessionManager/stores/cookieStorage.test.ts create mode 100644 lib/sessionManager/stores/cookieStorage.ts diff --git a/lib/main.ts b/lib/main.ts index 711474c..233c1ec 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -55,9 +55,11 @@ export type { export { storageSettings, MemoryStorage, + KvStorage, ChromeStore, LocalStorage, - KvStorage, + CookieStorage, + createGenericCookieAdapter, StorageKeys, } from "./sessionManager"; @@ -77,4 +79,4 @@ export const ExpoSecureStore: { }, }; -export type { SessionManager } from "./sessionManager"; +export type { SessionManager, CookieAdapter, CookieOptions } from "./sessionManager"; \ No newline at end of file diff --git a/lib/sessionManager/index.ts b/lib/sessionManager/index.ts index 1a3a286..2228094 100644 --- a/lib/sessionManager/index.ts +++ b/lib/sessionManager/index.ts @@ -26,7 +26,9 @@ export { ChromeStore } from "./stores/chromeStore.js"; export { ExpoSecureStore } from "./stores/expoSecureStore.js"; export { LocalStorage } from "./stores/localStorage.ts"; export { KvStorage } from "./stores/kvStorage.ts"; +export { CookieStorage, createGenericCookieAdapter } from "./stores/cookieStorage.ts"; +export type { CookieAdapter, CookieOptions } from "./stores/cookieStorage.ts"; -// Export types directly export { StorageKeys } from "./types.ts"; export type { SessionManager } from "./types.ts"; + diff --git a/lib/sessionManager/stores/cookieStorage.test.ts b/lib/sessionManager/stores/cookieStorage.test.ts new file mode 100644 index 0000000..67293cd --- /dev/null +++ b/lib/sessionManager/stores/cookieStorage.test.ts @@ -0,0 +1,303 @@ +// js-utils/lib/sessionManager/stores/cookieStorage.test.ts +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { CookieStorage, createGenericCookieAdapter, type CookieAdapter, type CookieOptions } from "./cookieStorage"; +import { StorageKeys } from "../types"; +import { storageSettings } from ".."; + +enum ExtraKeys { + testKey = "testKey2", +} + +// Mock cookie adapter for testing +const createMockCookieAdapter = () => { + const cookies: Record = {}; + + return { + cookies, // Expose for testing + adapter: { + get: (name: string) => cookies[name] || null, + set: (name: string, value: string, options?: CookieOptions) => { + cookies[name] = value; + }, + delete: (name: string, options?: CookieOptions) => { + delete cookies[name]; + } + } as CookieAdapter, + clear: () => { + Object.keys(cookies).forEach(key => delete cookies[key]); + } + }; +}; + +describe("CookieStorage standard keys", () => { + let sessionManager: CookieStorage; + let mockCookies: ReturnType; + const consoleSpy = vi.spyOn(console, "warn"); + + beforeEach(() => { + mockCookies = createMockCookieAdapter(); + sessionManager = new CookieStorage(mockCookies.adapter); + consoleSpy.mockClear(); + mockCookies.clear(); + }); + + it("should show warning when using insecure refresh token setting", () => { + storageSettings.useInsecureForRefreshToken = true; + new CookieStorage(mockCookies.adapter); + expect(consoleSpy).toHaveBeenCalledWith( + "CookieStorage: useInsecureForRefreshToken is enabled - refresh tokens will be stored in cookies which may have security implications" + ); + storageSettings.useInsecureForRefreshToken = false; + }); + + it("should not show warning when using secure settings", () => { + storageSettings.useInsecureForRefreshToken = false; + new CookieStorage(mockCookies.adapter); + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + it("should set and get an item in cookie storage", async () => { + await sessionManager.setSessionItem(StorageKeys.accessToken, "testValue"); + expect(await sessionManager.getSessionItem(StorageKeys.accessToken)).toBe( + "testValue", + ); + }); + + it("should remove an item from cookie storage", async () => { + await sessionManager.setSessionItem(StorageKeys.accessToken, "testValue"); + expect(await sessionManager.getSessionItem(StorageKeys.accessToken)).toBe( + "testValue", + ); + + await sessionManager.removeSessionItem(StorageKeys.accessToken); + expect( + await sessionManager.getSessionItem(StorageKeys.accessToken), + ).toBeNull(); + }); + + it("should clear all items from cookie storage", async () => { + await sessionManager.setSessionItem(StorageKeys.accessToken, "testValue"); + expect(await sessionManager.getSessionItem(StorageKeys.accessToken)).toBe( + "testValue", + ); + + await sessionManager.destroySession(); + expect( + await sessionManager.getSessionItem(StorageKeys.accessToken), + ).toBeNull(); + }); + + it("should set many items", async () => { + await sessionManager.setItems({ + [StorageKeys.accessToken]: "accessTokenValue", + [StorageKeys.idToken]: "idTokenValue", + }); + expect(await sessionManager.getSessionItem(StorageKeys.accessToken)).toBe( + "accessTokenValue", + ); + expect(await sessionManager.getSessionItem(StorageKeys.idToken)).toBe( + "idTokenValue", + ); + }); + + it("should handle large strings by chunking", async () => { + const largeString = "x".repeat(sessionManager.getMaxChunkSize() * 2.5); + await sessionManager.setSessionItem(StorageKeys.accessToken, largeString); + + // Check that multiple chunks were created + const chunkCount = Object.keys(mockCookies.cookies).filter(key => + key.startsWith(`${storageSettings.keyPrefix}${StorageKeys.accessToken}`) + ).length; + expect(chunkCount).toBeGreaterThan(1); + + // Verify we can retrieve the full string + expect(await sessionManager.getSessionItem(StorageKeys.accessToken)).toBe( + largeString, + ); + }); + + it("should handle non-string values", async () => { + const objectValue = { test: "value", number: 42 }; + await sessionManager.setSessionItem(StorageKeys.state, objectValue); + + const retrieved = await sessionManager.getSessionItem(StorageKeys.state); + expect(retrieved).toBe(JSON.stringify(objectValue)); + }); + + it("should use default options", () => { + const defaultOptions = sessionManager.getDefaultOptions(); + expect(defaultOptions.httpOnly).toBe(true); + expect(defaultOptions.secure).toBe(true); + expect(defaultOptions.sameSite).toBe('lax'); + expect(defaultOptions.path).toBe('/'); + expect(defaultOptions.maxAge).toBe(900); + }); + + it("should allow custom options in constructor", () => { + const customStorage = new CookieStorage(mockCookies.adapter, { + defaultOptions: { + maxAge: 1800, + sameSite: 'strict' + } + }); + + const options = customStorage.getDefaultOptions(); + expect(options.maxAge).toBe(1800); + expect(options.sameSite).toBe('strict'); + expect(options.httpOnly).toBe(true); // Should keep other defaults + }); + + it("should allow updating default options", () => { + sessionManager.setDefaultOptions({ maxAge: 3600 }); + const options = sessionManager.getDefaultOptions(); + expect(options.maxAge).toBe(3600); + }); + + it("should use custom chunk size", () => { + const customStorage = new CookieStorage(mockCookies.adapter, { + maxChunkSize: 1000 + }); + expect(customStorage.getMaxChunkSize()).toBe(1000); + }); +}); + +describe("CookieStorage custom keys", () => { + let sessionManager: CookieStorage; + let mockCookies: ReturnType; + + beforeEach(() => { + mockCookies = createMockCookieAdapter(); + sessionManager = new CookieStorage(mockCookies.adapter); + mockCookies.clear(); + }); + + it("should set and get an item with custom key type", async () => { + await sessionManager.setSessionItem(ExtraKeys.testKey, "testValue"); + expect(await sessionManager.getSessionItem(ExtraKeys.testKey)).toBe( + "testValue", + ); + }); + + it("should still work with standard StorageKeys", async () => { + await sessionManager.setSessionItem(StorageKeys.accessToken, "testValue"); + expect(await sessionManager.getSessionItem(StorageKeys.accessToken)).toBe( + "testValue", + ); + }); + + it("should clear all items including standard keys", async () => { + await sessionManager.setSessionItem(ExtraKeys.testKey, "testValue"); + await sessionManager.setSessionItem(StorageKeys.accessToken, "tokenValue"); + + await sessionManager.destroySession(); + + expect(await sessionManager.getSessionItem(ExtraKeys.testKey)).toBeNull(); + expect(await sessionManager.getSessionItem(StorageKeys.accessToken)).toBeNull(); + }); +}); + +describe("CookieStorage error handling", () => { + let sessionManager: CookieStorage; + let mockAdapter: any; + + beforeEach(() => { + mockAdapter = { + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + }; + sessionManager = new CookieStorage(mockAdapter); + }); + + it("should handle get errors gracefully", async () => { + mockAdapter.get.mockImplementation(() => { + throw new Error("Cookie error"); + }); + + const result = await sessionManager.getSessionItem(StorageKeys.accessToken); + expect(result).toBeNull(); + }); + + it("should throw on set errors", async () => { + mockAdapter.set.mockImplementation(() => { + throw new Error("Cookie error"); + }); + + await expect( + sessionManager.setSessionItem(StorageKeys.accessToken, "value") + ).rejects.toThrow("Cookie error"); + }); + + it("should throw on remove errors", async () => { + mockAdapter.get.mockReturnValue("value"); + mockAdapter.delete.mockImplementation(() => { + throw new Error("Cookie error"); + }); + + await expect( + sessionManager.removeSessionItem(StorageKeys.accessToken) + ).rejects.toThrow("Cookie error"); + }); +}); + +describe("createGenericCookieAdapter", () => { + it("should create a working cookie adapter", () => { + const mockCookies: Record = {}; + + const adapter = createGenericCookieAdapter( + (name) => mockCookies[name], + (name, value) => { mockCookies[name] = value; }, + (name) => { delete mockCookies[name]; } + ); + + adapter.set("test", "value"); + expect(adapter.get("test")).toBe("value"); + + adapter.delete("test"); + expect(adapter.get("test")).toBeUndefined(); + }); +}); + +describe("CookieStorage chunking behavior", () => { + let sessionManager: CookieStorage; + let mockCookies: ReturnType; + + beforeEach(() => { + mockCookies = createMockCookieAdapter(); + // Use small chunk size for testing + sessionManager = new CookieStorage(mockCookies.adapter, { + maxChunkSize: 10 + }); + mockCookies.clear(); + }); + + it("should chunk large values correctly", async () => { + const testValue = "0123456789abcdefghij"; // 20 chars, should create 2 chunks + await sessionManager.setSessionItem(StorageKeys.state, testValue); + + // Should have 2 cookies + const cookieKeys = Object.keys(mockCookies.cookies); + expect(cookieKeys).toHaveLength(2); + expect(cookieKeys).toContain(`${storageSettings.keyPrefix}state0`); + expect(cookieKeys).toContain(`${storageSettings.keyPrefix}state1`); + + // Should reconstruct correctly + const retrieved = await sessionManager.getSessionItem(StorageKeys.state); + expect(retrieved).toBe(testValue); + }); + + it("should handle removing chunked items completely", async () => { + const testValue = "0123456789abcdefghij"; // 20 chars, creates 2 chunks + await sessionManager.setSessionItem(StorageKeys.state, testValue); + + // Verify chunks exist + expect(Object.keys(mockCookies.cookies)).toHaveLength(2); + + // Remove the item + await sessionManager.removeSessionItem(StorageKeys.state); + + // All chunks should be gone + expect(Object.keys(mockCookies.cookies)).toHaveLength(0); + expect(await sessionManager.getSessionItem(StorageKeys.state)).toBeNull(); + }); +}); \ No newline at end of file diff --git a/lib/sessionManager/stores/cookieStorage.ts b/lib/sessionManager/stores/cookieStorage.ts new file mode 100644 index 0000000..d1fe2ad --- /dev/null +++ b/lib/sessionManager/stores/cookieStorage.ts @@ -0,0 +1,230 @@ +import { storageSettings } from "../index.js"; +import { SessionBase, StorageKeys, type SessionManager } from "../types.js"; +import { splitString } from "../../utils/splitString.js"; + +/** + * Cookie adapter interface for framework-agnostic cookie operations + */ +export interface CookieAdapter { + set(name: string, value: string, options?: CookieOptions): void; + get(name: string): string | undefined | null; + delete(name: string, options?: CookieOptions): void; +} + +/** + * Cookie options interface matching common cookie attributes + */ +export interface CookieOptions { + httpOnly?: boolean; + secure?: boolean; + sameSite?: 'strict' | 'lax' | 'none'; + maxAge?: number; + expires?: Date; + path?: string; + domain?: string; +} + +/** + * Default cookie options for secure session management + */ +const DEFAULT_COOKIE_OPTIONS: CookieOptions = { + httpOnly: true, + secure: true, + sameSite: 'lax', + path: '/', + maxAge: 900, +}; + +/** + * Provides a cookie-based session manager implementation for server-side environments. + * Designed for temporary data that requires immediate consistency (OAuth state, nonce, etc.) + * + * @class CookieStorage + */ +export class CookieStorage + extends SessionBase + implements SessionManager +{ + private cookieAdapter: CookieAdapter; + private defaultOptions: CookieOptions; + private maxChunkSize: number; + + constructor( + cookieAdapter: CookieAdapter, + options?: { + defaultOptions?: Partial; + maxChunkSize?: number; + } + ) { + super(); + this.cookieAdapter = cookieAdapter; + + this.defaultOptions = { + ...DEFAULT_COOKIE_OPTIONS, + ...(options?.defaultOptions || {}) + }; + + + this.maxChunkSize = options?.maxChunkSize || Math.min(storageSettings.maxLength, 3000); + + if (storageSettings.useInsecureForRefreshToken) { + console.warn( + "CookieStorage: useInsecureForRefreshToken is enabled - refresh tokens will be stored in cookies which may have security implications" + ); + } + } + + /** + * Clears all items from cookie storage. + * Note: This removes all cookies with the configured key prefix + * @returns {Promise} + */ + async destroySession(): Promise { + const allKeys = Object.values(StorageKeys); + await Promise.all( + allKeys.map(key => this.removeSessionItem(key)) + ); + } + + /** + * Sets the provided key-value pair to cookie storage. + * Large values are automatically chunked across multiple cookies. + * @param {V | StorageKeys} itemKey + * @param {unknown} itemValue + * @returns {Promise} + */ + async setSessionItem( + itemKey: V | StorageKeys, + itemValue: unknown, + ): Promise { + try { + await this.removeSessionItem(itemKey); + + if (typeof itemValue === "string") { + const chunks = splitString(itemValue, this.maxChunkSize); + + chunks.forEach((chunk, index) => { + const cookieName = `${storageSettings.keyPrefix}${itemKey}${index}`; + this.cookieAdapter.set(cookieName, chunk, this.defaultOptions); + }); + + return; + } + + const stringValue = typeof itemValue === 'object' + ? JSON.stringify(itemValue) + : String(itemValue); + + const cookieName = `${storageSettings.keyPrefix}${String(itemKey)}0`; + this.cookieAdapter.set(cookieName, stringValue, this.defaultOptions); + + } catch (error) { + console.error(`CookieStorage: Failed to set session item ${String(itemKey)}:`, error); + throw error; + } + } + + /** + * Gets the item for the provided key from cookie storage. + * Automatically reconstructs chunked values. + * @param {V | StorageKeys} itemKey + * @returns {Promise} + */ + async getSessionItem(itemKey: V | StorageKeys): Promise { + try { + const firstChunkName = `${storageSettings.keyPrefix}${String(itemKey)}0`; + const firstChunk = this.cookieAdapter.get(firstChunkName); + + if (!firstChunk) { + return null; + } + + let itemValue = ""; + let index = 0; + let currentChunk: string | undefined | null = firstChunk; + + while (currentChunk) { + itemValue += currentChunk; + index++; + + const nextChunkName = `${storageSettings.keyPrefix}${String(itemKey)}${index}`; + currentChunk = this.cookieAdapter.get(nextChunkName); + } + + return itemValue; + + } catch (error) { + console.error(`CookieStorage: Failed to get session item ${String(itemKey)}:`, error); + return null; + } + } + + /** + * Removes the item for the provided key from cookie storage. + * Removes all chunks associated with the key. + * @param {V | StorageKeys} itemKey + * @returns {Promise} + */ + async removeSessionItem(itemKey: V | StorageKeys): Promise { + try { + let index = 0; + let hasMore = true; + + while (hasMore) { + const cookieName = `${storageSettings.keyPrefix}${String(itemKey)}${index}`; + const value = this.cookieAdapter.get(cookieName); + + if (value) { + this.cookieAdapter.delete(cookieName, { path: this.defaultOptions.path }); + index++; + } else { + hasMore = false; + } + } + + } catch (error) { + console.error(`CookieStorage: Failed to remove session item ${String(itemKey)}:`, error); + throw error; + } + } + + /** + * Updates the default cookie options for future operations + * @param options + */ + setDefaultOptions(options: Partial): void { + this.defaultOptions = { + ...this.defaultOptions, + ...options + }; + } + + /** + * Gets the current default cookie options + */ + getDefaultOptions(): CookieOptions { + return { ...this.defaultOptions }; + } + + /** + * Gets the maximum chunk size used for splitting large values + */ + getMaxChunkSize(): number { + return this.maxChunkSize; + } +} + +/** + * Helper function to create a generic cookie adapter from common cookie interfaces + */ +export function createGenericCookieAdapter( + getCookie: (name: string) => string | undefined | null, + setCookie: (name: string, value: string, options?: CookieOptions) => void, + deleteCookie: (name: string, options?: CookieOptions) => void +): CookieAdapter { + return { + get: getCookie, + set: setCookie, + delete: deleteCookie + }; +} \ No newline at end of file diff --git a/lib/sessionManager/stores/kvStorage.test.ts b/lib/sessionManager/stores/kvStorage.test.ts index b7a6a31..979e782 100644 --- a/lib/sessionManager/stores/kvStorage.test.ts +++ b/lib/sessionManager/stores/kvStorage.test.ts @@ -7,15 +7,34 @@ enum ExtraKeys { testKey = "testKey2", } +// Mock Cloudflare KV interface with controllable behavior const createMockKV = () => { const store: Record = {}; + let getDelay = 0; + let putDelay = 0; + let simulateEventualConsistency = false; + let consistencyCounter = 0; return { async get(key: string): Promise { + if (getDelay > 0) { + await new Promise(resolve => setTimeout(resolve, getDelay)); + } + + // Simulate eventual consistency - first few reads return null even if data exists + if (simulateEventualConsistency && store[key] && consistencyCounter < 2) { + consistencyCounter++; + return null; + } + return store[key] || null; }, async put(key: string, value: string, options?: { expirationTtl?: number }): Promise { + if (putDelay > 0) { + await new Promise(resolve => setTimeout(resolve, putDelay)); + } store[key] = value; + consistencyCounter = 0; // Reset consistency simulation }, async delete(key: string): Promise { delete store[key]; @@ -29,14 +48,22 @@ const createMockKV = () => { keys: filteredKeys.map(name => ({ name })) }; }, + // Test helpers _getStore: () => ({ ...store }), _clear: () => { Object.keys(store).forEach(key => delete store[key]); + consistencyCounter = 0; + }, + _setGetDelay: (ms: number) => { getDelay = ms; }, + _setPutDelay: (ms: number) => { putDelay = ms; }, + _simulateEventualConsistency: (enabled: boolean) => { + simulateEventualConsistency = enabled; + consistencyCounter = 0; } }; }; -describe("KvStorage standard keys", () => { +describe("KvStorage standard functionality", () => { let sessionManager: KvStorage; let mockKV: ReturnType; const consoleSpy = vi.spyOn(console, "warn"); @@ -56,12 +83,6 @@ describe("KvStorage standard keys", () => { storageSettings.useInsecureForRefreshToken = false; }); - it("should not show warning when using secure refresh tokens", () => { - storageSettings.useInsecureForRefreshToken = false; - new KvStorage(mockKV); - expect(consoleSpy).not.toHaveBeenCalled(); - }); - it("should set and get an item in session storage", async () => { await sessionManager.setSessionItem(StorageKeys.accessToken, "testValue"); expect(await sessionManager.getSessionItem(StorageKeys.accessToken)).toBe( @@ -93,54 +114,182 @@ describe("KvStorage standard keys", () => { ).toBeNull(); }); - it("should set many items", async () => { - await sessionManager.setItems({ - [StorageKeys.accessToken]: "accessTokenValue", - [StorageKeys.idToken]: "idTokenValue", - }); - expect(await sessionManager.getSessionItem(StorageKeys.accessToken)).toBe( - "accessTokenValue", - ); - expect(await sessionManager.getSessionItem(StorageKeys.idToken)).toBe( - "idTokenValue", - ); - }); - it("should handle large strings by chunking", async () => { const largeString = "x".repeat(storageSettings.maxLength * 2.5); // 5000 chars await sessionManager.setSessionItem(StorageKeys.accessToken, largeString); + // Check that multiple chunks were created const store = mockKV._getStore(); const chunks = Object.keys(store).filter(key => key.startsWith(`${storageSettings.keyPrefix}${StorageKeys.accessToken}`) ); expect(chunks.length).toBeGreaterThan(1); + // Verify we can retrieve the full string expect(await sessionManager.getSessionItem(StorageKeys.accessToken)).toBe( largeString, ); }); - it("should handle non-string values by casting to string", async () => { - await sessionManager.setSessionItem(StorageKeys.state, true); - expect(await sessionManager.getSessionItem(StorageKeys.state)).toBe("true"); + it("should handle non-string values", async () => { + const objectValue = { test: "value", number: 42 }; + await sessionManager.setSessionItem(StorageKeys.state, objectValue); - await sessionManager.setSessionItem(StorageKeys.state, 42); - expect(await sessionManager.getSessionItem(StorageKeys.state)).toBe("42"); + const retrieved = await sessionManager.getSessionItem(StorageKeys.state); + expect(retrieved).toBe(JSON.stringify(objectValue)); }); +}); - it("should use default TTL", () => { - expect(sessionManager.getDefaultTtl()).toBe(3600); +describe("KvStorage consistency features", () => { + let sessionManager: KvStorage; + let mockKV: ReturnType; + const consoleSpy = vi.spyOn(console, "warn"); + + beforeEach(() => { + mockKV = createMockKV(); + consoleSpy.mockClear(); }); - it("should allow setting custom TTL", () => { - sessionManager.setDefaultTtl(7200); - expect(sessionManager.getDefaultTtl()).toBe(7200); + it("should use default consistency options", () => { + sessionManager = new KvStorage(mockKV); + const options = sessionManager.getConsistencyOptions(); + + expect(options.enabled).toBe(true); + expect(options.retries).toBe(3); + expect(options.delayMs).toBe(250); }); - it("should create with custom TTL in constructor", () => { - const customManager = new KvStorage(mockKV, { defaultTtl: 1800 }); - expect(customManager.getDefaultTtl()).toBe(1800); + it("should accept custom consistency options in constructor", () => { + sessionManager = new KvStorage(mockKV, { + enableConsistencyChecks: false, + consistencyRetries: 5, + consistencyDelayMs: 500 + }); + + const options = sessionManager.getConsistencyOptions(); + expect(options.enabled).toBe(false); + expect(options.retries).toBe(5); + expect(options.delayMs).toBe(500); + }); + + it("should allow updating consistency options", () => { + sessionManager = new KvStorage(mockKV); + + sessionManager.setConsistencyOptions({ + enabled: false, + retries: 2, + delayMs: 100 + }); + + const options = sessionManager.getConsistencyOptions(); + expect(options.enabled).toBe(false); + expect(options.retries).toBe(2); + expect(options.delayMs).toBe(100); + }); + + it("should handle eventual consistency during read operations", async () => { + sessionManager = new KvStorage(mockKV, { + enableConsistencyChecks: true, + consistencyRetries: 3, + consistencyDelayMs: 10 // Faster for testing + }); + + // First store the value normally + await sessionManager.setSessionItem(StorageKeys.accessToken, "testValue"); + + // Clear the store and simulate eventual consistency + mockKV._clear(); + mockKV._getStore()[`${storageSettings.keyPrefix}${StorageKeys.accessToken}0`] = "testValue"; + mockKV._simulateEventualConsistency(true); + + // Should eventually succeed with retries + const result = await sessionManager.getSessionItem(StorageKeys.accessToken); + expect(result).toBe("testValue"); + }); + + it("should skip retries when consistency checks disabled", async () => { + sessionManager = new KvStorage(mockKV, { + enableConsistencyChecks: false + }); + + mockKV._simulateEventualConsistency(true); + + // Should return null immediately without retries + const result = await sessionManager.getSessionItem(StorageKeys.accessToken); + expect(result).toBeNull(); + }); + + it("should verify writes when consistency checks enabled", async () => { + sessionManager = new KvStorage(mockKV, { + enableConsistencyChecks: true, + consistencyDelayMs: 10 // Faster for testing + }); + + mockKV._simulateEventualConsistency(true); + + // Should complete successfully despite initial read failures + await sessionManager.setSessionItem(StorageKeys.accessToken, "testValue"); + + // Value should be readable after write completes + const result = await sessionManager.getSessionItem(StorageKeys.accessToken); + expect(result).toBe("testValue"); + }); + + it("should warn when consistency verification fails", async () => { + sessionManager = new KvStorage(mockKV, { + enableConsistencyChecks: true, + consistencyRetries: 2, + consistencyDelayMs: 1 + }); + + // Simulate persistent inconsistency + const originalGet = mockKV.get; + mockKV.get = vi.fn().mockResolvedValue(null); + + await sessionManager.setSessionItem(StorageKeys.accessToken, "testValue"); + + expect(consoleSpy).toHaveBeenCalledWith( + `KvStorage: Consistency check failed for ${StorageKeys.accessToken} after 2 attempts` + ); + + // Restore original method + mockKV.get = originalGet; + }); + + it("should use sequential writes for setItems with consistency checks", async () => { + sessionManager = new KvStorage(mockKV, { + enableConsistencyChecks: true, + consistencyDelayMs: 1 + }); + + const putSpy = vi.spyOn(mockKV, 'put'); + + await sessionManager.setItems({ + [StorageKeys.accessToken]: "accessValue", + [StorageKeys.idToken]: "idValue", + }); + + // Should call put multiple times (clearing + setting for each item) + expect(putSpy).toHaveBeenCalled(); + + // Both values should be stored + expect(await sessionManager.getSessionItem(StorageKeys.accessToken)).toBe("accessValue"); + expect(await sessionManager.getSessionItem(StorageKeys.idToken)).toBe("idValue"); + }); + + it("should use parallel writes for setItems with consistency checks disabled", async () => { + sessionManager = new KvStorage(mockKV, { + enableConsistencyChecks: false + }); + + await sessionManager.setItems({ + [StorageKeys.accessToken]: "accessValue", + [StorageKeys.idToken]: "idValue", + }); + + // Both values should be stored + expect(await sessionManager.getSessionItem(StorageKeys.accessToken)).toBe("accessValue"); + expect(await sessionManager.getSessionItem(StorageKeys.idToken)).toBe("idValue"); }); }); @@ -221,4 +370,89 @@ describe("KvStorage error handling", () => { await expect(sessionManager.destroySession()).rejects.toThrow("KV error"); }); +}); + +describe("KvStorage TTL functionality", () => { + let sessionManager: KvStorage; + let mockKV: ReturnType; + + beforeEach(() => { + mockKV = createMockKV(); + }); + + it("should use default TTL", () => { + sessionManager = new KvStorage(mockKV); + expect(sessionManager.getDefaultTtl()).toBe(3600); + }); + + it("should allow setting custom TTL", () => { + sessionManager = new KvStorage(mockKV); + sessionManager.setDefaultTtl(7200); + expect(sessionManager.getDefaultTtl()).toBe(7200); + }); + + it("should create with custom TTL in constructor", () => { + sessionManager = new KvStorage(mockKV, { defaultTtl: 1800 }); + expect(sessionManager.getDefaultTtl()).toBe(1800); + }); + + it("should pass TTL to KV put operations", async () => { + sessionManager = new KvStorage(mockKV, { defaultTtl: 1200 }); + const putSpy = vi.spyOn(mockKV, 'put'); + + await sessionManager.setSessionItem(StorageKeys.accessToken, "testValue"); + + expect(putSpy).toHaveBeenCalledWith( + expect.stringContaining(StorageKeys.accessToken), + "testValue", + { expirationTtl: 1200 } + ); + }); +}); + +describe("KvStorage performance scenarios", () => { + let sessionManager: KvStorage; + let mockKV: ReturnType; + + beforeEach(() => { + mockKV = createMockKV(); + }); + + it("should handle slow KV operations gracefully", async () => { + sessionManager = new KvStorage(mockKV, { + enableConsistencyChecks: true, + consistencyDelayMs: 10 + }); + + // Simulate slow KV operations + mockKV._setPutDelay(20); + mockKV._setGetDelay(20); + + const start = Date.now(); + await sessionManager.setSessionItem(StorageKeys.accessToken, "testValue"); + const result = await sessionManager.getSessionItem(StorageKeys.accessToken); + const duration = Date.now() - start; + + expect(result).toBe("testValue"); + expect(duration).toBeGreaterThan(30); // Should account for delays + }); + + it("should timeout appropriately with consistency retries", async () => { + sessionManager = new KvStorage(mockKV, { + enableConsistencyChecks: true, + consistencyRetries: 2, + consistencyDelayMs: 50 + }); + + // Make reads always fail + mockKV.get = vi.fn().mockResolvedValue(null); + + const start = Date.now(); + const result = await sessionManager.getSessionItem(StorageKeys.accessToken); + const duration = Date.now() - start; + + expect(result).toBeNull(); + // Should have tried 2 times with 50ms and 100ms delays + expect(duration).toBeGreaterThan(140); // 50 + 100 + some overhead + }); }); \ No newline at end of file diff --git a/lib/sessionManager/stores/kvStorage.ts b/lib/sessionManager/stores/kvStorage.ts index fb4120f..2a97b46 100644 --- a/lib/sessionManager/stores/kvStorage.ts +++ b/lib/sessionManager/stores/kvStorage.ts @@ -9,8 +9,16 @@ interface CloudflareKV { list(options?: { prefix?: string }): Promise<{ keys: Array<{ name: string }> }>; } +interface KvStorageOptions { + defaultTtl?: number; + enableConsistencyChecks?: boolean; + consistencyRetries?: number; + consistencyDelayMs?: number; +} + /** * Provides a Cloudflare KV based session manager implementation for server-side environments. + * Includes built-in eventual consistency handling for reliable operations. * @class KvStorage */ export class KvStorage @@ -19,11 +27,17 @@ export class KvStorage { private kvNamespace: CloudflareKV; private defaultTtl: number; + private enableConsistencyChecks: boolean; + private consistencyRetries: number; + private consistencyDelayMs: number; - constructor(kvNamespace: CloudflareKV, options?: { defaultTtl?: number }) { + constructor(kvNamespace: CloudflareKV, options: KvStorageOptions = {}) { super(); this.kvNamespace = kvNamespace; - this.defaultTtl = options?.defaultTtl || 3600; + this.defaultTtl = options.defaultTtl || 3600; + this.enableConsistencyChecks = options.enableConsistencyChecks ?? true; + this.consistencyRetries = options.consistencyRetries ?? 3; + this.consistencyDelayMs = options.consistencyDelayMs ?? 250; if (storageSettings.useInsecureForRefreshToken) { console.warn("KvStorage: useInsecureForRefreshToken is enabled - consider security implications for refresh tokens in KV storage"); @@ -50,10 +64,7 @@ export class KvStorage } /** - * Sets the provided key-value store to the KV storage. - * @param {string} itemKey - * @param {unknown} itemValue - * @returns {void} + * Sets the provided key-value store to the KV storage with optional consistency verification. */ async setSessionItem( itemKey: V | StorageKeys, @@ -62,7 +73,7 @@ export class KvStorage try { await this.removeSessionItem(itemKey); - if (typeof itemValue === "string") { + if (typeof itemValue === "string") { const chunks = splitString(itemValue, storageSettings.maxLength); await Promise.all( @@ -74,14 +85,21 @@ export class KvStorage ) ) ); - return; + } else { + const value = typeof itemValue === 'object' + ? JSON.stringify(itemValue) + : String(itemValue); + + await this.kvNamespace.put( + `${storageSettings.keyPrefix}${String(itemKey)}0`, + value, + { expirationTtl: this.defaultTtl } + ); + } + + if (this.enableConsistencyChecks) { + await this.waitForConsistency(itemKey, itemValue); } - - await this.kvNamespace.put( - `${storageSettings.keyPrefix}${String(itemKey)}0`, - itemValue as string, - { expirationTtl: this.defaultTtl } - ); } catch (error) { console.error(`KvStorage: Failed to set session item ${String(itemKey)}:`, error); throw error; @@ -89,11 +107,30 @@ export class KvStorage } /** - * Gets the item for the provided key from the KV storage. - * @param {string} itemKey - * @returns {unknown | null} + * Gets the item for the provided key from the KV storage with retry logic for eventual consistency */ async getSessionItem(itemKey: V | StorageKeys): Promise { + if (!this.enableConsistencyChecks) { + return this._getSessionItemOnce(itemKey); + } + + for (let attempt = 0; attempt < this.consistencyRetries; attempt++) { + const result = await this._getSessionItemOnce(itemKey); + + if (result !== null || attempt === this.consistencyRetries - 1) { + return result; + } + + await this.delay(this.consistencyDelayMs * (attempt + 1)); + } + + return null; + } + + /** + * Internal method to get session item without retries + */ + private async _getSessionItemOnce(itemKey: V | StorageKeys): Promise { try { const firstChunk = await this.kvNamespace.get( `${storageSettings.keyPrefix}${String(itemKey)}0` @@ -123,6 +160,58 @@ export class KvStorage } } + /** + * Waits for write consistency by verifying the written value can be read back + */ + private async waitForConsistency( + itemKey: V | StorageKeys, + expectedValue: unknown + ): Promise { + const expectedString = typeof expectedValue === 'string' + ? expectedValue + : typeof expectedValue === 'object' + ? JSON.stringify(expectedValue) + : String(expectedValue); + + for (let attempt = 0; attempt < this.consistencyRetries; attempt++) { + const readValue = await this._getSessionItemOnce(itemKey); + + if (readValue === expectedString) { + return; + } + + if (attempt < this.consistencyRetries - 1) { + await this.delay(this.consistencyDelayMs); + } + } + + console.warn(`KvStorage: Consistency check failed for ${String(itemKey)} after ${this.consistencyRetries} attempts`); + } + + /** + * Utility method for delays + */ + private delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * Sets multiple items with consistency verification + */ + async setItems(items: Record): Promise { + if (this.enableConsistencyChecks) { + for (const [key, value] of Object.entries(items)) { + await this.setSessionItem(key as V, value); + } + } else { + await Promise.all( + Object.entries(items).map(([key, value]) => + this.setSessionItem(key as V, value) + ) + ); + } + } + /** * Removes the item for the provided key from the KV storage. * @param {string} itemKey @@ -138,7 +227,7 @@ export class KvStorage const value = await this.kvNamespace.get(key); if (value === null) { - break; + break; } keysToDelete.push(key); @@ -168,4 +257,38 @@ export class KvStorage getDefaultTtl(): number { return this.defaultTtl; } + + /** + * Configure consistency behavior + */ + setConsistencyOptions(options: { + enabled?: boolean; + retries?: number; + delayMs?: number; + }): void { + if (options.enabled !== undefined) { + this.enableConsistencyChecks = options.enabled; + } + if (options.retries !== undefined) { + this.consistencyRetries = options.retries; + } + if (options.delayMs !== undefined) { + this.consistencyDelayMs = options.delayMs; + } + } + + /** + * Get current consistency settings + */ + getConsistencyOptions(): { + enabled: boolean; + retries: number; + delayMs: number; + } { + return { + enabled: this.enableConsistencyChecks, + retries: this.consistencyRetries, + delayMs: this.consistencyDelayMs, + }; + } } \ No newline at end of file