diff --git a/.github/workflows/cdn-deploy.yml b/.github/workflows/cdn-deploy.yml new file mode 100644 index 00000000..b26dd874 --- /dev/null +++ b/.github/workflows/cdn-deploy.yml @@ -0,0 +1,103 @@ +name: CDN Configuration Deploy + +on: + push: + branches: [main, develop] + paths: + - 'infra/fastly/**' + - '.github/workflows/cdn-deploy.yml' + workflow_dispatch: + inputs: + provider: + description: 'CDN provider' + required: true + default: 'fastly' + type: choice + options: + - fastly + - cloudflare + +env: + NODE_VERSION: '20' + +jobs: + validate-vcl: + name: Validate Fastly VCL + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Check VCL syntax (basic) + run: | + test -f infra/fastly/snippets/recv.vcl + test -f infra/fastly/snippets/fetch.vcl + grep -q "s-maxage" infra/fastly/snippets/fetch.vcl + grep -q "/plans" infra/fastly/snippets/recv.vcl + echo "VCL snippet validation passed" + + deploy-fastly: + name: Deploy Fastly VCL Snippet + needs: validate-vcl + if: > + github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && github.event.inputs.provider == 'fastly') + runs-on: ubuntu-latest + environment: production + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Deploy VCL snippet to Fastly + env: + FASTLY_API_TOKEN: ${{ secrets.FASTLY_API_TOKEN }} + FASTLY_SERVICE_ID: ${{ secrets.FASTLY_SERVICE_ID }} + run: | + if [ -z "$FASTLY_API_TOKEN" ] || [ -z "$FASTLY_SERVICE_ID" ]; then + echo "Fastly credentials not configured — skipping deploy (CI validation only)" + exit 0 + fi + chmod +x scripts/deploy-fastly-vcl.sh + ./scripts/deploy-fastly-vcl.sh infra/fastly/snippets + + deploy-cloudflare: + name: Deploy Cloudflare Cache Rules + needs: validate-vcl + if: github.event_name == 'workflow_dispatch' && github.event.inputs.provider == 'cloudflare' + runs-on: ubuntu-latest + environment: production + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Verify Cloudflare cache tag support + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }} + run: | + if [ -z "$CLOUDFLARE_API_TOKEN" ] || [ -z "$CLOUDFLARE_ZONE_ID" ]; then + echo "Cloudflare credentials not configured — skipping deploy" + exit 0 + fi + echo "Cloudflare purge-by-tag configured via CDN_PROVIDER=cloudflare" + echo "Origin sets Cache-Tag header alongside Surrogate-Key" + + run-cache-tests: + name: CDN Cache Unit Tests + needs: validate-vcl + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Run CDN cache tests + run: npm run test:cdn diff --git a/backend/__tests__/setup.ts b/backend/__tests__/setup.ts new file mode 100644 index 00000000..4246a6d7 --- /dev/null +++ b/backend/__tests__/setup.ts @@ -0,0 +1,2 @@ +/** Backend Jest setup – polyfill Expo/RN globals not present in Node. */ +(globalThis as { __DEV__?: boolean }).__DEV__ = false; diff --git a/backend/server/createApiServer.ts b/backend/server/createApiServer.ts new file mode 100644 index 00000000..586d92c7 --- /dev/null +++ b/backend/server/createApiServer.ts @@ -0,0 +1,51 @@ +/** + * SubTrackr public API HTTP server factory. + * + * Mounts CDN-cacheable routes behind edge-cache header middleware. + */ + +import express, { type Express } from 'express'; +import { cacheHeadersMiddleware } from '../shared/middleware'; +import { createPublicApiRouter } from '../subscription/router/publicApiRouter'; +import { API_VERSION_HEADER, API_VERSION_VALUE } from '../services/shared/apiResponse'; + +export interface CreateApiServerOptions { + /** Optional middleware applied before cache headers (e.g. auth). */ + beforeCache?: express.RequestHandler[]; +} + +export function createApiServer(options: CreateApiServerOptions = {}): Express { + const app = express(); + + app.disable('x-powered-by'); + app.use(express.json()); + + if (options.beforeCache) { + for (const mw of options.beforeCache) { + app.use(mw); + } + } + + app.use((_req, res, next) => { + res.setHeader(API_VERSION_HEADER, API_VERSION_VALUE); + next(); + }); + + app.use(cacheHeadersMiddleware()); + app.use(createPublicApiRouter()); + + app.use((_req, res) => { + res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Not found' } }); + }); + + return app; +} + +export function startApiServer(port: number = Number(process.env.PORT ?? 3000)): Express { + const app = createApiServer(); + app.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`SubTrackr API listening on port ${port}`); + }); + return app; +} diff --git a/backend/server/index.ts b/backend/server/index.ts new file mode 100644 index 00000000..5955addb --- /dev/null +++ b/backend/server/index.ts @@ -0,0 +1,2 @@ +export { createApiServer, startApiServer } from './createApiServer'; +export type { CreateApiServerOptions } from './createApiServer'; diff --git a/backend/server/start.ts b/backend/server/start.ts new file mode 100644 index 00000000..83c78ebc --- /dev/null +++ b/backend/server/start.ts @@ -0,0 +1,10 @@ +/** + * Entry point for the SubTrackr public API server. + * + * Usage: npm run api:start + * Env: PORT (default 3000), CDN_PROVIDER, CDN_API_TOKEN, CDN_SERVICE_ID + */ + +import { startApiServer } from './createApiServer'; + +startApiServer(); diff --git a/backend/services/shared/logging.ts b/backend/services/shared/logging.ts index 5524d8c1..4d1e70a9 100644 --- a/backend/services/shared/logging.ts +++ b/backend/services/shared/logging.ts @@ -7,8 +7,12 @@ const LOG_LEVEL_PRIORITY: Record = { error: 3, }; -// Change this via env later -const CURRENT_LEVEL: LogLevel = __DEV__ ? 'debug' : 'info'; +// Change this via env later (__DEV__ is an Expo/RN global; absent in plain Node) +const CURRENT_LEVEL: LogLevel = + typeof (globalThis as { __DEV__?: boolean }).__DEV__ !== 'undefined' && + (globalThis as { __DEV__?: boolean }).__DEV__ + ? 'debug' + : 'info'; // Correlation ID generator (simple version) const generateId = () => { diff --git a/backend/shared/cache/__tests__/cdnPurgeClient.test.ts b/backend/shared/cache/__tests__/cdnPurgeClient.test.ts new file mode 100644 index 00000000..786e914b --- /dev/null +++ b/backend/shared/cache/__tests__/cdnPurgeClient.test.ts @@ -0,0 +1,193 @@ +/** + * Tests for CDN purge API client (Fastly / Cloudflare). + */ + +import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; +import { + CdnPurgeClient, + NoOpCdnPurgeClient, + createCdnPurgeClientFromEnv, + resetCdnPurgeClient, + purgeSurrogateKeys, +} from '../cdnPurgeClient'; + +function mockFetch(status: number, body = ''): typeof fetch { + return jest.fn(async () => ({ + ok: status >= 200 && status < 300, + status, + text: async () => body, + })) as unknown as typeof fetch; +} + +// ── CdnPurgeClient – Fastly ─────────────────────────────────────────────────── + +describe('CdnPurgeClient – Fastly', () => { + it('sends purge request with Surrogate-Key header', async () => { + const fetchImpl = mockFetch(200); + const client = new CdnPurgeClient({ + provider: 'fastly', + apiToken: 'test-token', + serviceId: 'svc-123', + fetchImpl, + }); + + const result = await client.purgeBySurrogateKeys(['plan', 'pricing']); + + expect(result.success).toBe(true); + expect(result.provider).toBe('fastly'); + expect(fetchImpl).toHaveBeenCalledWith( + 'https://api.fastly.com/service/svc-123/purge', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Fastly-Key': 'test-token', + 'Surrogate-Key': 'plan pricing', + }), + }), + ); + }); + + it('logs error and returns failure on non-2xx response', async () => { + const fetchImpl = mockFetch(503, 'service unavailable'); + const client = new CdnPurgeClient({ + provider: 'fastly', + apiToken: 'test-token', + serviceId: 'svc-123', + fetchImpl, + }); + + const result = await client.purgeBySurrogateKeys(['plan']); + + expect(result.success).toBe(false); + expect(result.statusCode).toBe(503); + expect(result.error).toContain('503'); + }); + + it('returns success for empty key list without calling API', async () => { + const fetchImpl = mockFetch(200); + const client = new CdnPurgeClient({ + provider: 'fastly', + apiToken: 'test-token', + serviceId: 'svc-123', + fetchImpl, + }); + + const result = await client.purgeBySurrogateKeys([]); + + expect(result.success).toBe(true); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); + +// ── CdnPurgeClient – Cloudflare ─────────────────────────────────────────────── + +describe('CdnPurgeClient – Cloudflare', () => { + it('sends purge request with cache tags', async () => { + const fetchImpl = mockFetch(200); + const client = new CdnPurgeClient({ + provider: 'cloudflare', + apiToken: 'cf-token', + serviceId: 'zone-abc', + fetchImpl, + }); + + const result = await client.purgeBySurrogateKeys(['feature']); + + expect(result.success).toBe(true); + expect(fetchImpl).toHaveBeenCalledWith( + 'https://api.cloudflare.com/client/v4/zones/zone-abc/purge_cache', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ tags: ['feature'] }), + }), + ); + }); + + it('handles network errors without throwing', async () => { + const fetchImpl = jest.fn(async () => { + throw new Error('network timeout'); + }) as unknown as typeof fetch; + + const client = new CdnPurgeClient({ + provider: 'cloudflare', + apiToken: 'cf-token', + serviceId: 'zone-abc', + fetchImpl, + }); + + const result = await client.purgeBySurrogateKeys(['config']); + + expect(result.success).toBe(false); + expect(result.error).toContain('network timeout'); + }); +}); + +// ── NoOpCdnPurgeClient ──────────────────────────────────────────────────────── + +describe('NoOpCdnPurgeClient', () => { + it('returns success without network calls', async () => { + const client = new NoOpCdnPurgeClient(); + const result = await client.purgeBySurrogateKeys(['plan']); + expect(result.success).toBe(true); + }); + + it('logs warning when keys provided without CDN credentials', async () => { + const warnSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const client = new NoOpCdnPurgeClient(); + await client.purgeBySurrogateKeys(['plan']); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('CDN purge skipped')); + warnSpy.mockRestore(); + }); + + it('does not log when key list is empty', async () => { + const warnSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const client = new NoOpCdnPurgeClient(); + await client.purgeBySurrogateKeys([]); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); +}); + +// ── createCdnPurgeClientFromEnv ───────────────────────────────────────────────── + +describe('createCdnPurgeClientFromEnv', () => { + afterEach(() => { + resetCdnPurgeClient(); + }); + + it('returns NoOp client when credentials are missing', () => { + const client = createCdnPurgeClientFromEnv({}); + expect(client).toBeInstanceOf(NoOpCdnPurgeClient); + }); + + it('returns configured client when env vars are set', () => { + const client = createCdnPurgeClientFromEnv({ + CDN_PROVIDER: 'fastly', + CDN_API_TOKEN: 'token', + CDN_SERVICE_ID: 'svc', + }); + expect(client).toBeInstanceOf(CdnPurgeClient); + expect(client.provider).toBe('fastly'); + }); +}); + +// ── purgeSurrogateKeys ────────────────────────────────────────────────────────── + +describe('purgeSurrogateKeys', () => { + beforeEach(() => { + resetCdnPurgeClient(); + }); + + it('delegates to injected client', async () => { + const fetchImpl = mockFetch(200); + const client = new CdnPurgeClient({ + provider: 'fastly', + apiToken: 'tok', + serviceId: 'svc', + fetchImpl, + }); + + const result = await purgeSurrogateKeys(['user'], client); + expect(result.success).toBe(true); + }); +}); diff --git a/backend/shared/cache/__tests__/surrogateKeys.test.ts b/backend/shared/cache/__tests__/surrogateKeys.test.ts new file mode 100644 index 00000000..23183bab --- /dev/null +++ b/backend/shared/cache/__tests__/surrogateKeys.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from '@jest/globals'; +import { SURROGATE_KEY, scopedSurrogateKey, formatSurrogateKeyHeader } from '../surrogateKeys'; + +describe('surrogateKeys', () => { + it('defines all required resource types', () => { + expect(SURROGATE_KEY.PLAN).toBe('plan'); + expect(SURROGATE_KEY.PRICING).toBe('pricing'); + expect(SURROGATE_KEY.FEATURE).toBe('feature'); + expect(SURROGATE_KEY.CONFIG).toBe('config'); + expect(SURROGATE_KEY.USER).toBe('user'); + }); + + it('builds scoped keys', () => { + expect(scopedSurrogateKey('plan', 'premium')).toBe('plan:premium'); + }); + + it('formats header with deduplication', () => { + expect(formatSurrogateKeyHeader(['plan', 'plan', 'pricing'])).toBe('plan pricing'); + }); + + it('filters empty keys', () => { + expect(formatSurrogateKeyHeader(['plan', '', 'pricing'])).toBe('plan pricing'); + }); +}); diff --git a/backend/shared/cache/cdnPurgeClient.ts b/backend/shared/cache/cdnPurgeClient.ts new file mode 100644 index 00000000..0554222c --- /dev/null +++ b/backend/shared/cache/cdnPurgeClient.ts @@ -0,0 +1,194 @@ +/** + * CDN purge API client for Fastly and Cloudflare edge caches. + * + * Purges cached responses by surrogate key (Fastly) or cache tag (Cloudflare). + * On API failure the error is logged and the caller continues — TTL expiry + * eventually clears stale content at the edge. + */ + +import { logger } from '../../services/shared/logging'; +import type { CdnProvider, CdnPurgeConfig, CdnPurgeResult } from './types'; + +const FASTLY_API_BASE = 'https://api.fastly.com'; +const CLOUDFLARE_API_BASE = 'https://api.cloudflare.com/client/v4'; + +export class CdnPurgeClient { + private readonly config: CdnPurgeConfig; + private readonly fetchImpl: typeof fetch; + + constructor(config: CdnPurgeConfig) { + this.config = config; + this.fetchImpl = config.fetchImpl ?? fetch; + } + + get provider(): CdnProvider { + return this.config.provider; + } + + /** + * Purge all cached objects tagged with any of the given surrogate keys. + * Never throws — failures are logged and returned in the result. + */ + async purgeBySurrogateKeys(keys: string[]): Promise { + const surrogateKeys = [...new Set(keys.filter(Boolean))]; + + if (surrogateKeys.length === 0) { + return { success: true, provider: this.config.provider, surrogateKeys: [] }; + } + + try { + if (this.config.provider === 'fastly') { + return await this.purgeFastly(surrogateKeys); + } + return await this.purgeCloudflare(surrogateKeys); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error('CDN purge request failed', { + provider: this.config.provider, + surrogateKeys, + error: message, + }); + return { + success: false, + provider: this.config.provider, + surrogateKeys, + error: message, + }; + } + } + + private async purgeFastly(surrogateKeys: string[]): Promise { + const url = `${FASTLY_API_BASE}/service/${this.config.serviceId}/purge`; + const response = await this.fetchImpl(url, { + method: 'POST', + headers: { + 'Fastly-Key': this.config.apiToken, + 'Surrogate-Key': surrogateKeys.join(' '), + Accept: 'application/json', + }, + }); + + if (!response.ok) { + const body = await safeReadBody(response); + const error = `Fastly purge failed (${response.status}): ${body}`; + logger.error('CDN purge API call failed', { + provider: 'fastly', + statusCode: response.status, + surrogateKeys, + error, + }); + return { + success: false, + provider: 'fastly', + surrogateKeys, + statusCode: response.status, + error, + }; + } + + logger.info('CDN purge succeeded', { provider: 'fastly', surrogateKeys }); + return { success: true, provider: 'fastly', surrogateKeys, statusCode: response.status }; + } + + private async purgeCloudflare(surrogateKeys: string[]): Promise { + const url = `${CLOUDFLARE_API_BASE}/zones/${this.config.serviceId}/purge_cache`; + const response = await this.fetchImpl(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.config.apiToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ tags: surrogateKeys }), + }); + + if (!response.ok) { + const body = await safeReadBody(response); + const error = `Cloudflare purge failed (${response.status}): ${body}`; + logger.error('CDN purge API call failed', { + provider: 'cloudflare', + statusCode: response.status, + surrogateKeys, + error, + }); + return { + success: false, + provider: 'cloudflare', + surrogateKeys, + statusCode: response.status, + error, + }; + } + + logger.info('CDN purge succeeded', { provider: 'cloudflare', surrogateKeys }); + return { success: true, provider: 'cloudflare', surrogateKeys, statusCode: response.status }; + } +} + +async function safeReadBody(response: Response): Promise { + try { + return await response.text(); + } catch { + return ''; + } +} + +/** No-op client used when CDN credentials are not configured. */ +export class NoOpCdnPurgeClient extends CdnPurgeClient { + constructor() { + super({ provider: 'fastly', apiToken: '', serviceId: '' }); + } + + async purgeBySurrogateKeys(keys: string[]): Promise { + const surrogateKeys = [...new Set(keys.filter(Boolean))]; + if (surrogateKeys.length > 0) { + logger.warn('CDN purge skipped — CDN_API_TOKEN or CDN_SERVICE_ID not configured', { + surrogateKeys, + }); + } + return { success: true, provider: 'fastly', surrogateKeys }; + } +} + +let _defaultClient: CdnPurgeClient | null = null; + +/** + * Build a purge client from environment variables. + * + * CDN_PROVIDER=fastly|cloudflare + * CDN_API_TOKEN= + * CDN_SERVICE_ID= + */ +export function createCdnPurgeClientFromEnv( + env: Record = process.env, +): CdnPurgeClient { + const provider = (env.CDN_PROVIDER ?? 'fastly') as CdnProvider; + const apiToken = env.CDN_API_TOKEN ?? ''; + const serviceId = env.CDN_SERVICE_ID ?? ''; + + if (!apiToken || !serviceId) { + return new NoOpCdnPurgeClient(); + } + + return new CdnPurgeClient({ provider, apiToken, serviceId }); +} + +/** Singleton purge client (lazy-initialized from env). */ +export function getCdnPurgeClient(): CdnPurgeClient { + if (!_defaultClient) { + _defaultClient = createCdnPurgeClientFromEnv(); + } + return _defaultClient; +} + +/** Reset singleton — for tests only. */ +export function resetCdnPurgeClient(): void { + _defaultClient = null; +} + +/** Convenience helper: purge keys and swallow errors (TTL fallback). */ +export async function purgeSurrogateKeys( + keys: string[], + client: CdnPurgeClient = getCdnPurgeClient(), +): Promise { + return client.purgeBySurrogateKeys(keys); +} diff --git a/backend/shared/cache/index.ts b/backend/shared/cache/index.ts index 8ba1197b..9cb676e9 100644 --- a/backend/shared/cache/index.ts +++ b/backend/shared/cache/index.ts @@ -1,3 +1,14 @@ +export { SURROGATE_KEY, scopedSurrogateKey, formatSurrogateKeyHeader } from './surrogateKeys'; +export type { SurrogateKeyType } from './surrogateKeys'; +export type { CdnProvider, CdnPurgeConfig, CdnPurgeResult } from './types'; +export { + CdnPurgeClient, + NoOpCdnPurgeClient, + createCdnPurgeClientFromEnv, + getCdnPurgeClient, + resetCdnPurgeClient, + purgeSurrogateKeys, +} from './cdnPurgeClient'; export { RedisCacheService } from './RedisCacheService'; export { createRedisClient, wrapIORedis } from './createRedisClient'; export { createNullRedisClient } from './NullRedisClient'; diff --git a/backend/shared/cache/surrogateKeys.ts b/backend/shared/cache/surrogateKeys.ts new file mode 100644 index 00000000..a6312cd3 --- /dev/null +++ b/backend/shared/cache/surrogateKeys.ts @@ -0,0 +1,25 @@ +/** + * CDN surrogate key resource types for granular edge-cache purging. + * + * Each cacheable response includes one or more of these keys in the + * Surrogate-Key response header so the CDN can purge by resource type. + */ +export const SURROGATE_KEY = { + PLAN: 'plan', + PRICING: 'pricing', + FEATURE: 'feature', + CONFIG: 'config', + USER: 'user', +} as const; + +export type SurrogateKeyType = (typeof SURROGATE_KEY)[keyof typeof SURROGATE_KEY]; + +/** Build a resource-scoped surrogate key, e.g. `plan:pro-monthly`. */ +export function scopedSurrogateKey(type: SurrogateKeyType, id: string): string { + return `${type}:${id}`; +} + +/** Format multiple keys for the Surrogate-Key response header. */ +export function formatSurrogateKeyHeader(keys: string[]): string { + return [...new Set(keys.filter(Boolean))].join(' '); +} diff --git a/backend/shared/cache/types.ts b/backend/shared/cache/types.ts index c737a123..ed0a6a70 100644 --- a/backend/shared/cache/types.ts +++ b/backend/shared/cache/types.ts @@ -1,8 +1,24 @@ /** - * Minimal Redis client interface for cache services. - * Compatible with ioredis, node-redis, and test doubles. + * Shared cache types — Redis application cache and CDN edge purge. */ +export type CdnProvider = 'fastly' | 'cloudflare'; + +export interface CdnPurgeConfig { + provider: CdnProvider; + apiToken: string; + serviceId: string; + fetchImpl?: typeof fetch; +} + +export interface CdnPurgeResult { + success: boolean; + provider: CdnProvider; + surrogateKeys: string[]; + statusCode?: number; + error?: string; +} + export interface RedisClient { get(key: string): Promise; set(key: string, value: string, expiryMode: 'EX', time: number): Promise; @@ -19,22 +35,13 @@ export interface RedisCacheMetrics { invalidations: number; errors: number; degradations: number; - /** hits / (hits + misses). NaN when no reads yet. */ hitRatio: number; - latencyMs: { - p50: number; - p95: number; - p99: number; - }; - /** Approximate serialized payload bytes currently tracked in metrics. */ + latencyMs: { p50: number; p95: number; p99: number }; memoryUsageBytes: number; } export interface RedisCacheConfig { - /** Key prefix for namespacing. */ keyPrefix?: string; - /** Default TTL in seconds when not overridden per entry. */ defaultTtlSeconds?: number; - /** Optional warning logger for Redis degradation events. */ onDegradation?: (message: string, context?: Record) => void; } diff --git a/backend/shared/middleware/__tests__/cacheHeaders.test.ts b/backend/shared/middleware/__tests__/cacheHeaders.test.ts new file mode 100644 index 00000000..41140e3e --- /dev/null +++ b/backend/shared/middleware/__tests__/cacheHeaders.test.ts @@ -0,0 +1,232 @@ +/** + * Tests for CDN edge-cache header middleware. + */ + +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import { + DEFAULT_CACHE_TTL_SECONDS, + STALE_WHILE_REVALIDATE_SECONDS, + buildCacheControlHeader, + clampTtl, + resolveTtlFromRequest, + applyCacheHeaders, + isCacheableRoute, + cacheHeadersMiddleware, + CACHE_CONTROL_HEADER, + SURROGATE_KEY_HEADER, +} from '../cacheHeaders'; + +// ── buildCacheControlHeader ─────────────────────────────────────────────────── + +describe('buildCacheControlHeader', () => { + it('uses default TTL and stale-while-revalidate', () => { + const header = buildCacheControlHeader(); + expect(header).toBe( + `public, s-maxage=${DEFAULT_CACHE_TTL_SECONDS}, max-age=${DEFAULT_CACHE_TTL_SECONDS}, stale-while-revalidate=${STALE_WHILE_REVALIDATE_SECONDS}`, + ); + }); + + it('honours custom TTL', () => { + const header = buildCacheControlHeader(120); + expect(header).toContain('s-maxage=120'); + expect(header).toContain('max-age=120'); + }); + + it('honours custom stale-while-revalidate', () => { + const header = buildCacheControlHeader(300, 30); + expect(header).toContain('stale-while-revalidate=30'); + }); +}); + +// ── clampTtl ────────────────────────────────────────────────────────────────── + +describe('clampTtl', () => { + it('returns default for non-finite values', () => { + expect(clampTtl(NaN)).toBe(DEFAULT_CACHE_TTL_SECONDS); + expect(clampTtl(0)).toBe(DEFAULT_CACHE_TTL_SECONDS); + expect(clampTtl(-10)).toBe(DEFAULT_CACHE_TTL_SECONDS); + }); + + it('clamps to minimum of 1 second', () => { + expect(clampTtl(0.5)).toBe(1); + }); + + it('clamps to maximum of 3600 seconds', () => { + expect(clampTtl(9999)).toBe(3600); + }); + + it('floors fractional values', () => { + expect(clampTtl(150.9)).toBe(150); + }); +}); + +// ── resolveTtlFromRequest ───────────────────────────────────────────────────── + +describe('resolveTtlFromRequest', () => { + it('returns default when header is absent', () => { + expect(resolveTtlFromRequest({ headers: {} })).toBe(DEFAULT_CACHE_TTL_SECONDS); + }); + + it('parses x-cache-ttl header', () => { + expect(resolveTtlFromRequest({ headers: { 'x-cache-ttl': '120' } })).toBe(120); + }); + + it('falls back to default for invalid header', () => { + expect(resolveTtlFromRequest({ headers: { 'x-cache-ttl': 'abc' } })).toBe( + DEFAULT_CACHE_TTL_SECONDS, + ); + }); + + it('handles array header values', () => { + expect(resolveTtlFromRequest({ headers: { 'x-cache-ttl': ['60', '120'] } })).toBe(60); + }); + + it('clamps out-of-range values', () => { + expect(resolveTtlFromRequest({ headers: { 'x-cache-ttl': '99999' } })).toBe(3600); + }); +}); + +// ── applyCacheHeaders ───────────────────────────────────────────────────────── + +describe('applyCacheHeaders', () => { + it('sets Cache-Control header', () => { + const headers: Record = {}; + const target = { setHeader: (k: string, v: string) => { headers[k] = v; } }; + + applyCacheHeaders(target, { ttlSeconds: 300 }); + + expect(headers[CACHE_CONTROL_HEADER]).toContain('s-maxage=300'); + expect(headers[CACHE_CONTROL_HEADER]).toContain('stale-while-revalidate=60'); + }); + + it('sets Surrogate-Key header when keys provided', () => { + const headers: Record = {}; + const target = { setHeader: (k: string, v: string) => { headers[k] = v; } }; + + applyCacheHeaders(target, { surrogateKeys: ['plan', 'plan:basic'] }); + + expect(headers[SURROGATE_KEY_HEADER]).toBe('plan plan:basic'); + expect(headers['Cache-Tag']).toBe('plan plan:basic'); + }); + + it('deduplicates surrogate keys', () => { + const headers: Record = {}; + const target = { setHeader: (k: string, v: string) => { headers[k] = v; } }; + + applyCacheHeaders(target, { surrogateKeys: ['plan', 'plan', 'pricing'] }); + + expect(headers[SURROGATE_KEY_HEADER]).toBe('plan pricing'); + }); + + it('omits Surrogate-Key when keys array is empty', () => { + const headers: Record = {}; + const target = { setHeader: (k: string, v: string) => { headers[k] = v; } }; + + applyCacheHeaders(target, { surrogateKeys: [] }); + + expect(headers[SURROGATE_KEY_HEADER]).toBeUndefined(); + }); +}); + +// ── isCacheableRoute ────────────────────────────────────────────────────────── + +describe('isCacheableRoute', () => { + it('matches GET /plans', () => { + expect(isCacheableRoute('GET', '/plans')).toBe(true); + expect(isCacheableRoute('get', '/plans/')).toBe(true); + }); + + it('matches GET /pricing', () => { + expect(isCacheableRoute('GET', '/pricing')).toBe(true); + }); + + it('matches GET /features', () => { + expect(isCacheableRoute('GET', '/features')).toBe(true); + }); + + it('matches GET /public/* paths', () => { + expect(isCacheableRoute('GET', '/public')).toBe(true); + expect(isCacheableRoute('GET', '/public/app/version')).toBe(true); + expect(isCacheableRoute('GET', '/public/billing/currencies')).toBe(true); + }); + + it('rejects non-GET methods', () => { + expect(isCacheableRoute('POST', '/plans')).toBe(false); + expect(isCacheableRoute('PATCH', '/pricing')).toBe(false); + }); + + it('rejects non-cacheable paths', () => { + expect(isCacheableRoute('GET', '/subscriptions')).toBe(false); + expect(isCacheableRoute('GET', '/private/config')).toBe(false); + }); + + it('strips query string before matching', () => { + expect(isCacheableRoute('GET', '/plans?cursor=abc')).toBe(true); + }); +}); + +// ── cacheHeadersMiddleware ──────────────────────────────────────────────────── + +describe('cacheHeadersMiddleware', () => { + function makeReqRes(method: string, path: string, headers: Record = {}) { + const req = { + method, + path, + headers, + } as any; + + const headersOut: Record = {}; + const res = { + headersSent: false, + locals: {} as Record, + setHeader: (k: string, v: string) => { headersOut[k] = v; }, + end: jest.fn(function (this: any) { return this; }), + } as any; + + const originalEnd = res.end; + res.end = jest.fn(function (this: any, ...args: unknown[]) { + return originalEnd.apply(this, args); + }); + + const next = jest.fn(); + return { req, res, headersOut, next }; + } + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('skips non-GET requests', () => { + const { req, res, next } = makeReqRes('POST', '/plans'); + cacheHeadersMiddleware()(req, res, next); + res.end(); + expect(next).toHaveBeenCalled(); + }); + + it('skips non-cacheable routes when routeFilter is true', () => { + const { req, res, next } = makeReqRes('GET', '/subscriptions'); + cacheHeadersMiddleware()(req, res, next); + res.end(); + expect(next).toHaveBeenCalled(); + }); + + it('applies cache headers on response end for cacheable routes', () => { + const { req, res, next, headersOut } = makeReqRes('GET', '/plans'); + res.locals.surrogateKeys = ['plan']; + + cacheHeadersMiddleware()(req, res, next); + res.end(); + + expect(headersOut[CACHE_CONTROL_HEADER]).toContain('s-maxage=300'); + expect(headersOut[SURROGATE_KEY_HEADER]).toBe('plan'); + }); + + it('respects x-cache-ttl from request', () => { + const { req, res, next, headersOut } = makeReqRes('GET', '/pricing', { 'x-cache-ttl': '60' }); + + cacheHeadersMiddleware()(req, res, next); + res.end(); + + expect(headersOut[CACHE_CONTROL_HEADER]).toContain('s-maxage=60'); + }); +}); diff --git a/backend/shared/middleware/cacheHeaders.ts b/backend/shared/middleware/cacheHeaders.ts new file mode 100644 index 00000000..8635d523 --- /dev/null +++ b/backend/shared/middleware/cacheHeaders.ts @@ -0,0 +1,143 @@ +/** + * CDN edge-cache header middleware. + * + * Sets Cache-Control and Surrogate-Key headers on cacheable GET responses: + * GET /plans, GET /pricing, GET /features, GET /public/* + * + * Default TTL: 5 minutes (300 s), overridable per-request via x-cache-ttl. + * Includes stale-while-revalidate=60 for background revalidation at the edge. + */ + +import type { Request, Response, NextFunction } from 'express'; +import { formatSurrogateKeyHeader } from '../cache/surrogateKeys'; + +// ── Constants ───────────────────────────────────────────────────────────────── + +export const DEFAULT_CACHE_TTL_SECONDS = 300; +export const STALE_WHILE_REVALIDATE_SECONDS = 60; +export const X_CACHE_TTL_HEADER = 'x-cache-ttl'; +export const CACHE_CONTROL_HEADER = 'Cache-Control'; +export const SURROGATE_KEY_HEADER = 'Surrogate-Key'; +/** Cloudflare cache tags (mirrors surrogate keys for purge-by-tag). */ +export const CACHE_TAG_HEADER = 'Cache-Tag'; + +/** Route patterns eligible for CDN edge caching (method + path). */ +export const CACHEABLE_ROUTES: ReadonlyArray<{ method: string; pattern: RegExp }> = [ + { method: 'GET', pattern: /^\/plans\/?$/ }, + { method: 'GET', pattern: /^\/pricing\/?$/ }, + { method: 'GET', pattern: /^\/features\/?$/ }, + { method: 'GET', pattern: /^\/public(?:\/.*)?$/ }, +]; + +// ── Header builders ─────────────────────────────────────────────────────────── + +export interface CacheHeaderOptions { + ttlSeconds?: number; + surrogateKeys?: string[]; + staleWhileRevalidateSeconds?: number; +} + +export interface CacheHeaderTarget { + setHeader(name: string, value: string): void; +} + +/** Build a Cache-Control value with s-maxage and stale-while-revalidate. */ +export function buildCacheControlHeader( + ttlSeconds: number = DEFAULT_CACHE_TTL_SECONDS, + staleWhileRevalidateSeconds: number = STALE_WHILE_REVALIDATE_SECONDS, +): string { + const ttl = clampTtl(ttlSeconds); + return `public, s-maxage=${ttl}, max-age=${ttl}, stale-while-revalidate=${staleWhileRevalidateSeconds}`; +} + +/** Clamp TTL to a sane range (1 s – 1 h). */ +export function clampTtl(ttlSeconds: number): number { + if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) { + return DEFAULT_CACHE_TTL_SECONDS; + } + return Math.min(Math.max(Math.floor(ttlSeconds), 1), 3600); +} + +/** Resolve TTL from the x-cache-ttl request header or fall back to default. */ +export function resolveTtlFromRequest( + req: Pick, + defaultTtl: number = DEFAULT_CACHE_TTL_SECONDS, +): number { + const raw = req.headers[X_CACHE_TTL_HEADER] ?? req.headers[X_CACHE_TTL_HEADER.toLowerCase()]; + const headerValue = Array.isArray(raw) ? raw[0] : raw; + if (headerValue === undefined || headerValue === '') { + return defaultTtl; + } + const parsed = Number.parseInt(String(headerValue), 10); + return clampTtl(Number.isNaN(parsed) ? defaultTtl : parsed); +} + +/** Apply Cache-Control and Surrogate-Key headers to a response object. */ +export function applyCacheHeaders(target: CacheHeaderTarget, options: CacheHeaderOptions = {}): void { + const ttl = clampTtl(options.ttlSeconds ?? DEFAULT_CACHE_TTL_SECONDS); + const swr = options.staleWhileRevalidateSeconds ?? STALE_WHILE_REVALIDATE_SECONDS; + + target.setHeader(CACHE_CONTROL_HEADER, buildCacheControlHeader(ttl, swr)); + + if (options.surrogateKeys && options.surrogateKeys.length > 0) { + const formatted = formatSurrogateKeyHeader(options.surrogateKeys); + target.setHeader(SURROGATE_KEY_HEADER, formatted); + target.setHeader(CACHE_TAG_HEADER, formatted); + } +} + +/** Check whether a request targets a cacheable route. */ +export function isCacheableRoute(method: string, path: string): boolean { + const normalizedPath = path.split('?')[0] || '/'; + return CACHEABLE_ROUTES.some( + (route) => route.method === method.toUpperCase() && route.pattern.test(normalizedPath), + ); +} + +// ── Express middleware ──────────────────────────────────────────────────────── + +export interface CacheHeadersMiddlewareOptions { + defaultTtlSeconds?: number; + /** When true, only cacheable routes receive headers (default: true). */ + routeFilter?: boolean; +} + +/** + * Express middleware that attaches edge-cache headers to eligible GET responses. + * + * Controllers may also call `applyCacheHeaders` directly with resource-specific + * surrogate keys; this middleware provides a baseline for matched routes. + */ +export function cacheHeadersMiddleware( + options: CacheHeadersMiddlewareOptions = {}, +): (req: Request, res: Response, next: NextFunction) => void { + const defaultTtl = options.defaultTtlSeconds ?? DEFAULT_CACHE_TTL_SECONDS; + const routeFilter = options.routeFilter ?? true; + + return (req: Request, res: Response, next: NextFunction): void => { + if (req.method.toUpperCase() !== 'GET') { + next(); + return; + } + + if (routeFilter && !isCacheableRoute(req.method, req.path)) { + next(); + return; + } + + const ttl = resolveTtlFromRequest(req, defaultTtl); + + // Defer header application until the handler sends the response so that + // route handlers can attach surrogate keys via res.locals first. + const originalEnd = res.end.bind(res); + res.end = ((...args: Parameters) => { + if (!res.headersSent) { + const surrogateKeys = (res.locals?.surrogateKeys as string[] | undefined) ?? []; + applyCacheHeaders(res, { ttlSeconds: ttl, surrogateKeys }); + } + return originalEnd(...args); + }) as Response['end']; + + next(); + }; +} diff --git a/backend/shared/middleware/index.ts b/backend/shared/middleware/index.ts new file mode 100644 index 00000000..45d0775d --- /dev/null +++ b/backend/shared/middleware/index.ts @@ -0,0 +1,16 @@ +export { + DEFAULT_CACHE_TTL_SECONDS, + STALE_WHILE_REVALIDATE_SECONDS, + X_CACHE_TTL_HEADER, + CACHE_CONTROL_HEADER, + SURROGATE_KEY_HEADER, + CACHE_TAG_HEADER, + CACHEABLE_ROUTES, + buildCacheControlHeader, + clampTtl, + resolveTtlFromRequest, + applyCacheHeaders, + isCacheableRoute, + cacheHeadersMiddleware, +} from './cacheHeaders'; +export type { CacheHeaderOptions, CacheHeaderTarget, CacheHeadersMiddlewareOptions } from './cacheHeaders'; diff --git a/backend/subscription/controller/__tests__/controllers.test.ts b/backend/subscription/controller/__tests__/controllers.test.ts new file mode 100644 index 00000000..17c2dcaa --- /dev/null +++ b/backend/subscription/controller/__tests__/controllers.test.ts @@ -0,0 +1,168 @@ +/** + * Tests for subscription CDN-cacheable endpoint controllers. + */ + +import { describe, it, expect, jest } from '@jest/globals'; +import { getPlans, getPlanById } from '../plansController'; +import { getPublicPricing } from '../pricingController'; +import { getFeatures } from '../featuresController'; +import { getPublicConfig } from '../publicController'; +import { + updatePlan, + updatePricing, + updateFeature, + updatePublicConfig, + purgeUserCache, +} from '../mutationController'; +import { SURROGATE_KEY } from '../../../shared/cache/surrogateKeys'; +import { NoOpCdnPurgeClient, CdnPurgeClient } from '../../../shared/cache/cdnPurgeClient'; +import { publicDataStore } from '../../store/publicDataStore'; + +const noopPurge = new NoOpCdnPurgeClient(); + +// ── GET /plans ──────────────────────────────────────────────────────────────── + +describe('getPlans', () => { + it('returns plans with plan surrogate keys', async () => { + const result = await getPlans(); + expect(result.response.success).toBe(true); + expect(result.response.data!.length).toBeGreaterThan(0); + expect(result.surrogateKeys).toContain(SURROGATE_KEY.PLAN); + expect(result.surrogateKeys).toContain('plan:free'); + }); + + it('uses custom data provider', async () => { + const result = await getPlans({ + listPlans: () => [{ id: 'custom', name: 'Custom', price: 1, currency: 'USD', billingCycle: 'monthly' }], + }); + expect(result.response.data).toHaveLength(1); + expect(result.surrogateKeys).toContain('plan:custom'); + }); +}); + +describe('getPlanById', () => { + it('returns single plan with scoped surrogate key', async () => { + const result = await getPlanById('premium'); + expect(result).not.toBeNull(); + expect(result!.response.data!.id).toBe('premium'); + expect(result!.surrogateKeys).toEqual(['plan', 'plan:premium']); + }); + + it('returns null for unknown plan', async () => { + const result = await getPlanById('nonexistent'); + expect(result).toBeNull(); + }); +}); + +// ── GET /pricing ────────────────────────────────────────────────────────────── + +describe('getPublicPricing', () => { + it('returns pricing with pricing surrogate key', async () => { + const result = await getPublicPricing(); + expect(result.response.success).toBe(true); + expect(result.surrogateKeys).toEqual([SURROGATE_KEY.PRICING]); + }); +}); + +// ── GET /features ───────────────────────────────────────────────────────────── + +describe('getFeatures', () => { + it('returns features with feature surrogate key', () => { + const result = getFeatures(); + expect(result.response.success).toBe(true); + expect(result.surrogateKeys).toEqual([SURROGATE_KEY.FEATURE]); + }); + + it('handles empty feature list', () => { + const result = getFeatures({ listPublicFeatures: () => [] }); + expect(result.response.data).toEqual([]); + expect(result.surrogateKeys).toContain(SURROGATE_KEY.FEATURE); + }); +}); + +// ── GET /public/* ───────────────────────────────────────────────────────────── + +describe('getPublicConfig', () => { + it('returns all config entries for root path', () => { + const result = getPublicConfig(''); + expect(result.response.success).toBe(true); + expect(Array.isArray(result.response.data)).toBe(true); + expect(result.surrogateKeys).toEqual([SURROGATE_KEY.CONFIG]); + }); + + it('returns scoped config for specific path', () => { + const result = getPublicConfig('app/version'); + expect(result.response.data).toMatchObject({ key: 'app/version' }); + expect(result.surrogateKeys).toContain('config:app/version'); + }); + + it('returns null value for unknown config key', () => { + const result = getPublicConfig('unknown/key'); + expect(result.response.data).toMatchObject({ key: 'unknown/key', value: null }); + }); +}); + +// ── Mutations with purge ────────────────────────────────────────────────────── + +describe('mutation purge handlers', () => { + beforeEach(() => { + publicDataStore.reset(); + }); + + it('updatePlan purges plan surrogate keys and persists', async () => { + const result = await updatePlan('basic', { price: 5.99 }, undefined, noopPurge); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.result.response.data!.price).toBe(5.99); + expect(result.result.purgeKeys).toContain(SURROGATE_KEY.PLAN); + } + expect(publicDataStore.getPlan('basic')?.price).toBe(5.99); + }); + + it('updatePricing purges pricing surrogate key and persists', async () => { + const result = await updatePricing('basic', { monthlyPrice: 5.99 }, undefined, noopPurge); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.result.purgeKeys).toContain(SURROGATE_KEY.PRICING); + } + expect(publicDataStore.getPricing('basic')?.monthlyPrice).toBe(5.99); + }); + + it('updateFeature purges feature surrogate key', async () => { + const result = await updateFeature('feat-1', false, undefined, noopPurge); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.result.purgeKeys).toContain(SURROGATE_KEY.FEATURE); + } + expect(publicDataStore.getFeatureOverride('feat-1')).toBe(false); + }); + + it('updatePublicConfig purges config surrogate keys and persists', async () => { + const result = await updatePublicConfig('app/version', { minSupported: '2.0.0' }, undefined, noopPurge); + expect(result.purgeKeys).toContain(SURROGATE_KEY.CONFIG); + expect(publicDataStore.getPublicConfigEntry('app/version')).toEqual({ minSupported: '2.0.0' }); + }); + + it('purgeUserCache does not throw', async () => { + await expect(purgeUserCache('user-123', noopPurge)).resolves.toBeUndefined(); + }); + + it('continues when purge API fails', async () => { + const failingFetch = jest.fn(async () => { + throw new Error('purge failed'); + }) as unknown as typeof fetch; + + const failingClient = new CdnPurgeClient({ + provider: 'fastly', + apiToken: 'tok', + serviceId: 'svc', + fetchImpl: failingFetch, + }); + + const result = await updatePlan('basic', { price: 6.99 }, undefined, failingClient); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.result.response.data!.price).toBe(6.99); + } + }); +}); diff --git a/backend/subscription/controller/featuresController.ts b/backend/subscription/controller/featuresController.ts new file mode 100644 index 00000000..dd9788bd --- /dev/null +++ b/backend/subscription/controller/featuresController.ts @@ -0,0 +1,48 @@ +/** + * GET /features – public feature flags and gating config (CDN-cacheable). + */ + +import { ok } from '../../services/shared/apiResponse'; +import { backendFeatureFlagsService } from '../../services/featureFlags'; +import { SURROGATE_KEY } from '../../shared/cache/surrogateKeys'; +import { publicDataStore } from '../store/publicDataStore'; +import type { CacheableEndpointResult } from './types'; + +export interface PublicFeatureSummary { + id: string; + name: string; + enabled: boolean; + tierAccess: string[]; +} + +export interface FeaturesDataProvider { + listPublicFeatures(): PublicFeatureSummary[]; +} + +const storeFeaturesProvider: FeaturesDataProvider = { + listPublicFeatures(): PublicFeatureSummary[] { + const features = backendFeatureFlagsService.getAllFeatures(); + return Object.entries(features).map(([id, feature]) => { + const override = publicDataStore.getFeatureOverride(id); + return { + id, + name: feature.name, + enabled: override !== undefined ? override : feature.enabled, + tierAccess: feature.tierAccess, + }; + }); + }, +}; + +/** GET /features */ +export function getFeatures( + provider: FeaturesDataProvider = storeFeaturesProvider, + requestId?: string, +): CacheableEndpointResult { + const features = provider.listPublicFeatures(); + + return { + response: ok(features, requestId), + surrogateKeys: [SURROGATE_KEY.FEATURE], + }; +} diff --git a/backend/subscription/controller/index.ts b/backend/subscription/controller/index.ts new file mode 100644 index 00000000..06d108a6 --- /dev/null +++ b/backend/subscription/controller/index.ts @@ -0,0 +1,56 @@ +import type { Request, Response } from 'express'; +import { + applyCacheHeaders, + resolveTtlFromRequest, +} from '../../shared/middleware/cacheHeaders'; +import { REQUEST_ID_HEADER } from '../../services/shared/apiResponse'; +import type { CacheableEndpointResult } from './types'; + +export { getPlans, getPlanById } from './plansController'; +export type { PublicPlan, PlansDataProvider } from './plansController'; + +export { getPublicPricing } from './pricingController'; +export type { PublicPricingTier, PricingDataProvider } from './pricingController'; + +export { getFeatures } from './featuresController'; +export type { PublicFeatureSummary, FeaturesDataProvider } from './featuresController'; + +export { getPublicConfig } from './publicController'; +export type { PublicConfigEntry } from './publicController'; + +export { + updatePlan, + updatePricing, + updateFeature, + updatePublicConfig, + purgeUserCache, +} from './mutationController'; +export type { UpdatePlanBody, UpdatePricingBody, MutationResult } from './mutationController'; + +export type { CacheableEndpointResult, CacheableMutationResult } from './types'; + +/** Extract request ID from incoming headers. */ +export function extractRequestId(req: Pick): string | undefined { + const raw = req.headers[REQUEST_ID_HEADER] ?? req.headers[REQUEST_ID_HEADER.toLowerCase()]; + return Array.isArray(raw) ? raw[0] : raw; +} + +/** + * Send a cacheable GET response with CDN edge-cache headers applied. + */ +export function sendCacheableResponse( + res: Response, + result: CacheableEndpointResult, + req?: Pick, +): void { + const ttl = result.cacheTtlSeconds ?? (req ? resolveTtlFromRequest(req) : undefined); + + applyCacheHeaders(res, { + ttlSeconds: ttl, + surrogateKeys: result.surrogateKeys, + }); + + res.locals.surrogateKeys = result.surrogateKeys; + + res.status(result.httpStatus ?? 200).json(result.response); +} diff --git a/backend/subscription/controller/mutationController.ts b/backend/subscription/controller/mutationController.ts new file mode 100644 index 00000000..fd24e279 --- /dev/null +++ b/backend/subscription/controller/mutationController.ts @@ -0,0 +1,131 @@ +/** + * Mutation handlers that purge CDN edge cache by surrogate key on update. + * + * Purge failures are logged by the CDN client; TTL expiry clears stale + * content eventually. + */ + +import { ok, fail } from '../../services/shared/apiResponse'; +import { purgeSurrogateKeys, type CdnPurgeClient } from '../../shared/cache'; +import { SURROGATE_KEY } from '../../shared/cache/surrogateKeys'; +import { publicDataStore } from '../store/publicDataStore'; +import type { CacheableMutationResult } from './types'; +import type { PublicPlan } from './plansController'; +import type { PublicPricingTier } from './pricingController'; + +export interface UpdatePlanBody { + name?: string; + price?: number; + currency?: string; + billingCycle?: 'monthly' | 'yearly'; +} + +export interface UpdatePricingBody { + monthlyPrice?: number; + yearlyPrice?: number; + discountPercent?: number; +} + +export type MutationResult = + | { ok: true; result: CacheableMutationResult } + | { ok: false; response: ReturnType; status: number }; + +/** PATCH /plans/:id – update plan and purge plan surrogate key. */ +export async function updatePlan( + planId: string, + body: UpdatePlanBody, + requestId?: string, + purgeClient?: CdnPurgeClient, +): Promise> { + const updated = publicDataStore.updatePlan(planId, body); + if (!updated) { + return { + ok: false, + response: fail('PLAN_NOT_FOUND', `Plan "${planId}" not found`, requestId), + status: 404, + }; + } + + await purgeSurrogateKeys([SURROGATE_KEY.PLAN, `${SURROGATE_KEY.PLAN}:${planId}`], purgeClient); + + return { + ok: true, + result: { + response: ok(updated, requestId), + purgeKeys: [SURROGATE_KEY.PLAN], + }, + }; +} + +/** PATCH /pricing/:planId – update pricing and purge pricing surrogate key. */ +export async function updatePricing( + planId: string, + body: UpdatePricingBody, + requestId?: string, + purgeClient?: CdnPurgeClient, +): Promise> { + const updated = publicDataStore.updatePricing(planId, body); + if (!updated) { + return { + ok: false, + response: fail('PLAN_NOT_FOUND', `Pricing for plan "${planId}" not found`, requestId), + status: 404, + }; + } + + await purgeSurrogateKeys([SURROGATE_KEY.PRICING], purgeClient); + + return { + ok: true, + result: { + response: ok(updated, requestId), + purgeKeys: [SURROGATE_KEY.PRICING], + }, + }; +} + +/** PATCH /features/:id – toggle feature and purge feature surrogate key. */ +export async function updateFeature( + featureId: string, + enabled: boolean, + requestId?: string, + purgeClient?: CdnPurgeClient, +): Promise> { + publicDataStore.setFeatureOverride(featureId, enabled); + await purgeSurrogateKeys([SURROGATE_KEY.FEATURE], purgeClient); + + return { + ok: true, + result: { + response: ok({ id: featureId, enabled }, requestId), + purgeKeys: [SURROGATE_KEY.FEATURE], + }, + }; +} + +/** PATCH /public/:path – update config and purge config surrogate key. */ +export async function updatePublicConfig( + configKey: string, + value: unknown, + requestId?: string, + purgeClient?: CdnPurgeClient, +): Promise> { + publicDataStore.updatePublicConfig(configKey, value); + await purgeSurrogateKeys( + [SURROGATE_KEY.CONFIG, `${SURROGATE_KEY.CONFIG}:${configKey}`], + purgeClient, + ); + + return { + response: ok({ key: configKey, value }, requestId), + purgeKeys: [SURROGATE_KEY.CONFIG], + }; +} + +/** Purge user-scoped cache entries (e.g. after profile update). */ +export async function purgeUserCache( + userId: string, + purgeClient?: CdnPurgeClient, +): Promise { + await purgeSurrogateKeys([SURROGATE_KEY.USER, `${SURROGATE_KEY.USER}:${userId}`], purgeClient); +} diff --git a/backend/subscription/controller/plansController.ts b/backend/subscription/controller/plansController.ts new file mode 100644 index 00000000..89d6acfa --- /dev/null +++ b/backend/subscription/controller/plansController.ts @@ -0,0 +1,74 @@ +/** + * GET /plans – list available subscription plans (CDN-cacheable). + */ + +import { ok } from '../../services/shared/apiResponse'; +import { SURROGATE_KEY, scopedSurrogateKey } from '../../shared/cache/surrogateKeys'; +import { publicDataStore } from '../store/publicDataStore'; +import type { CacheableEndpointResult } from './types'; + +export interface PublicPlan { + id: string; + name: string; + price: number; + currency: string; + billingCycle: 'monthly' | 'yearly'; +} + +export interface PlansDataProvider { + listPlans(): PublicPlan[] | Promise; + getPlan?(id: string): PublicPlan | undefined | Promise; +} + +export const storePlansProvider: PlansDataProvider = { + listPlans: () => publicDataStore.listPlans(), + getPlan: (id) => publicDataStore.getPlan(id), +}; + +const DEFAULT_PLANS: PublicPlan[] = [ + { id: 'free', name: 'Free', price: 0, currency: 'USD', billingCycle: 'monthly' }, + { id: 'basic', name: 'Basic', price: 4.99, currency: 'USD', billingCycle: 'monthly' }, + { id: 'premium', name: 'Premium', price: 9.99, currency: 'USD', billingCycle: 'monthly' }, + { id: 'enterprise', name: 'Enterprise', price: 29.99, currency: 'USD', billingCycle: 'monthly' }, +]; + +/** GET /plans */ +export async function getPlans( + provider: PlansDataProvider = storePlansProvider, + requestId?: string, +): Promise> { + const plans = await provider.listPlans(); + const surrogateKeys = [SURROGATE_KEY.PLAN, ...plans.map((p) => scopedSurrogateKey(SURROGATE_KEY.PLAN, p.id))]; + + return { + response: ok(plans, requestId), + surrogateKeys, + }; +} + +/** GET /plans/:id */ +export async function getPlanById( + planId: string, + provider: PlansDataProvider = storePlansProvider, + requestId?: string, +): Promise | null> { + if (provider.getPlan) { + const plan = await provider.getPlan(planId); + if (!plan) return null; + return { + response: ok(plan, requestId), + surrogateKeys: [SURROGATE_KEY.PLAN, scopedSurrogateKey(SURROGATE_KEY.PLAN, plan.id)], + }; + } + + const plans = await provider.listPlans(); + const plan = plans.find((p) => p.id === planId); + if (!plan) { + return null; + } + + return { + response: ok(plan, requestId), + surrogateKeys: [SURROGATE_KEY.PLAN, scopedSurrogateKey(SURROGATE_KEY.PLAN, plan.id)], + }; +} diff --git a/backend/subscription/controller/pricingController.ts b/backend/subscription/controller/pricingController.ts new file mode 100644 index 00000000..216187dc --- /dev/null +++ b/backend/subscription/controller/pricingController.ts @@ -0,0 +1,44 @@ +/** + * GET /pricing – public pricing tiers (CDN-cacheable). + */ + +import { ok } from '../../services/shared/apiResponse'; +import { SURROGATE_KEY } from '../../shared/cache/surrogateKeys'; +import { publicDataStore } from '../store/publicDataStore'; +import type { CacheableEndpointResult } from './types'; + +export interface PublicPricingTier { + planId: string; + monthlyPrice: number; + yearlyPrice: number; + currency: string; + discountPercent?: number; +} + +export interface PricingDataProvider { + getPublicPricing(): PublicPricingTier[] | Promise; +} + +export const storePricingProvider: PricingDataProvider = { + getPublicPricing: () => publicDataStore.listPricing(), +}; + +const DEFAULT_PRICING: PublicPricingTier[] = [ + { planId: 'free', monthlyPrice: 0, yearlyPrice: 0, currency: 'USD' }, + { planId: 'basic', monthlyPrice: 4.99, yearlyPrice: 49.99, currency: 'USD', discountPercent: 17 }, + { planId: 'premium', monthlyPrice: 9.99, yearlyPrice: 99.99, currency: 'USD', discountPercent: 17 }, + { planId: 'enterprise', monthlyPrice: 29.99, yearlyPrice: 299.99, currency: 'USD', discountPercent: 17 }, +]; + +/** GET /pricing */ +export async function getPublicPricing( + provider: PricingDataProvider = storePricingProvider, + requestId?: string, +): Promise> { + const tiers = await provider.getPublicPricing(); + + return { + response: ok(tiers, requestId), + surrogateKeys: [SURROGATE_KEY.PRICING], + }; +} diff --git a/backend/subscription/controller/publicController.ts b/backend/subscription/controller/publicController.ts new file mode 100644 index 00000000..2d6fb00d --- /dev/null +++ b/backend/subscription/controller/publicController.ts @@ -0,0 +1,49 @@ +/** + * GET /public/* – static public configuration (CDN-cacheable). + */ + +import { ok } from '../../services/shared/apiResponse'; +import { SURROGATE_KEY, scopedSurrogateKey } from '../../shared/cache/surrogateKeys'; +import { publicDataStore } from '../store/publicDataStore'; +import type { CacheableEndpointResult } from './types'; + +export interface PublicConfigEntry { + key: string; + value: unknown; +} + +const PUBLIC_CONFIG: Record = { + 'app/version': { minSupported: '1.0.0', latest: '1.0.0' }, + 'app/support': { email: 'support@subtrackr.app', docsUrl: 'https://docs.subtrackr.app' }, + 'billing/currencies': { supported: ['USD', 'EUR', 'GBP'] }, + 'onboarding/steps': { count: 4, skippable: true }, +}; + +function readPublicConfig(): Record { + return publicDataStore.listPublicConfig(); +} + +/** GET /public/:path* */ +export function getPublicConfig( + resourcePath: string, + requestId?: string, +): CacheableEndpointResult { + const normalized = resourcePath.replace(/^\/+/, '').replace(/\/+$/, ''); + + if (!normalized) { + const entries = Object.entries(readPublicConfig()).map(([key, value]) => ({ key, value })); + return { + response: ok(entries, requestId), + surrogateKeys: [SURROGATE_KEY.CONFIG], + }; + } + + const config = readPublicConfig(); + const value = config[normalized]; + const entry: PublicConfigEntry = { key: normalized, value: value ?? null }; + + return { + response: ok(entry, requestId), + surrogateKeys: [SURROGATE_KEY.CONFIG, scopedSurrogateKey(SURROGATE_KEY.CONFIG, normalized)], + }; +} diff --git a/backend/subscription/controller/types.ts b/backend/subscription/controller/types.ts new file mode 100644 index 00000000..276a0d5a --- /dev/null +++ b/backend/subscription/controller/types.ts @@ -0,0 +1,15 @@ +import type { ApiSuccessResponse } from '../../services/shared/apiResponse'; +import type { SurrogateKeyType } from '../../shared/cache/surrogateKeys'; + +/** Result returned by cacheable GET endpoint handlers. */ +export interface CacheableEndpointResult { + response: ApiSuccessResponse; + surrogateKeys: string[]; + cacheTtlSeconds?: number; + httpStatus?: number; +} + +export interface CacheableMutationResult { + response: ApiSuccessResponse; + purgeKeys: SurrogateKeyType[]; +} diff --git a/backend/subscription/router/__tests__/publicApiRouter.test.ts b/backend/subscription/router/__tests__/publicApiRouter.test.ts new file mode 100644 index 00000000..6dd2505f --- /dev/null +++ b/backend/subscription/router/__tests__/publicApiRouter.test.ts @@ -0,0 +1,90 @@ +/** + * Integration tests for CDN-cacheable public API routes. + */ + +import { describe, it, expect, beforeEach } from '@jest/globals'; +import request from 'supertest'; +import { createApiServer } from '../../../server/createApiServer'; +import { publicDataStore } from '../../store/publicDataStore'; +import { + CACHE_CONTROL_HEADER, + SURROGATE_KEY_HEADER, + CACHE_TAG_HEADER, +} from '../../../shared/middleware/cacheHeaders'; + +describe('Public API router (CDN cache headers)', () => { + const app = createApiServer(); + + beforeEach(() => { + publicDataStore.reset(); + }); + + it('GET /plans returns cache headers and surrogate keys', async () => { + const res = await request(app).get('/plans').expect(200); + + expect(res.headers[cacheControlKey(res)]).toContain('s-maxage=300'); + expect(res.headers[cacheControlKey(res)]).toContain('stale-while-revalidate=60'); + expect(res.headers[surrogateKeyHeader(res)]).toContain('plan'); + expect(res.headers[cacheTagHeader(res)]).toContain('plan'); + expect(res.body.success).toBe(true); + expect(res.body.data.length).toBeGreaterThan(0); + }); + + it('GET /pricing honours x-cache-ttl', async () => { + const res = await request(app).get('/pricing').set('x-cache-ttl', '120').expect(200); + + expect(res.headers[cacheControlKey(res)]).toContain('s-maxage=120'); + expect(res.headers[surrogateKeyHeader(res)]).toBe('pricing'); + expect(res.headers[cacheTagHeader(res)]).toBe('pricing'); + }); + + it('GET /features returns feature surrogate key', async () => { + const res = await request(app).get('/features').expect(200); + + expect(res.headers[surrogateKeyHeader(res)]).toBe('feature'); + expect(res.body.success).toBe(true); + }); + + it('GET /public/app/version returns scoped config surrogate keys', async () => { + const res = await request(app).get('/public/app/version').expect(200); + + expect(res.headers[surrogateKeyHeader(res)]).toContain('config'); + expect(res.headers[surrogateKeyHeader(res)]).toContain('config:app/version'); + expect(res.body.data.key).toBe('app/version'); + }); + + it('PATCH /plans/:id persists update and returns 200', async () => { + const res = await request(app).patch('/plans/basic').send({ price: 5.49 }).expect(200); + + expect(res.body.data.price).toBe(5.49); + + const getRes = await request(app).get('/plans/basic').expect(200); + expect(getRes.body.data.price).toBe(5.49); + }); + + it('PATCH /plans/:id returns 404 for unknown plan', async () => { + await request(app).patch('/plans/unknown').send({ price: 1 }).expect(404); + }); + + it('PATCH /public/app/version persists and is readable', async () => { + await request(app) + .patch('/public/app/version') + .send({ value: { minSupported: '2.0.0', latest: '2.0.0' } }) + .expect(200); + + const res = await request(app).get('/public/app/version').expect(200); + expect(res.body.data.value).toEqual({ minSupported: '2.0.0', latest: '2.0.0' }); + }); +}); + +function cacheControlKey(res: request.Response): string { + return Object.keys(res.headers).find((k) => k.toLowerCase() === CACHE_CONTROL_HEADER.toLowerCase())!; +} + +function surrogateKeyHeader(res: request.Response): string { + return Object.keys(res.headers).find((k) => k.toLowerCase() === SURROGATE_KEY_HEADER.toLowerCase())!; +} + +function cacheTagHeader(res: request.Response): string { + return Object.keys(res.headers).find((k) => k.toLowerCase() === CACHE_TAG_HEADER.toLowerCase())!; +} diff --git a/backend/subscription/router/index.ts b/backend/subscription/router/index.ts new file mode 100644 index 00000000..380cf575 --- /dev/null +++ b/backend/subscription/router/index.ts @@ -0,0 +1 @@ +export { createPublicApiRouter } from './publicApiRouter'; diff --git a/backend/subscription/router/publicApiRouter.ts b/backend/subscription/router/publicApiRouter.ts new file mode 100644 index 00000000..c808a2fa --- /dev/null +++ b/backend/subscription/router/publicApiRouter.ts @@ -0,0 +1,141 @@ +/** + * Express router for CDN-cacheable public subscription API endpoints. + * + * GET /plans + * GET /plans/:id + * PATCH /plans/:id + * GET /pricing + * PATCH /pricing/:planId + * GET /features + * PATCH /features/:id + * GET /public/* + * PATCH /public/* + */ + +import { Router, type Request, type Response, type NextFunction } from 'express'; +import { fail } from '../../services/shared/apiResponse'; +import { + extractRequestId, + getPlans, + getPlanById, + getPublicPricing, + getFeatures, + getPublicConfig, + sendCacheableResponse, + updatePlan, + updatePricing, + updateFeature, + updatePublicConfig, + type MutationResult, +} from '../controller'; + +type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise; + +function asyncHandler(fn: AsyncHandler) { + return (req: Request, res: Response, next: NextFunction): void => { + fn(req, res, next).catch(next); + }; +} + +function sendMutation(res: Response, outcome: MutationResult): void { + if (outcome.ok === false) { + res.status(outcome.status).json(outcome.response); + return; + } + res.status(200).json(outcome.result.response); +} + +export function createPublicApiRouter(): Router { + const router = Router(); + + // ── Plans ───────────────────────────────────────────────────────────────── + + router.get( + '/plans', + asyncHandler(async (req, res) => { + const result = await getPlans(undefined, extractRequestId(req)); + sendCacheableResponse(res, result, req); + }), + ); + + router.get( + '/plans/:id', + asyncHandler(async (req, res) => { + const result = await getPlanById(req.params.id, undefined, extractRequestId(req)); + if (!result) { + res.status(404).json(fail('PLAN_NOT_FOUND', `Plan "${req.params.id}" not found`, extractRequestId(req))); + return; + } + sendCacheableResponse(res, result, req); + }), + ); + + router.patch( + '/plans/:id', + asyncHandler(async (req, res) => { + const outcome = await updatePlan(req.params.id, req.body, extractRequestId(req)); + sendMutation(res, outcome); + }), + ); + + // ── Pricing ─────────────────────────────────────────────────────────────── + + router.get( + '/pricing', + asyncHandler(async (req, res) => { + const result = await getPublicPricing(undefined, extractRequestId(req)); + sendCacheableResponse(res, result, req); + }), + ); + + router.patch( + '/pricing/:planId', + asyncHandler(async (req, res) => { + const outcome = await updatePricing(req.params.planId, req.body, extractRequestId(req)); + sendMutation(res, outcome); + }), + ); + + // ── Features ────────────────────────────────────────────────────────────── + + router.get('/features', (req, res) => { + const result = getFeatures(undefined, extractRequestId(req)); + sendCacheableResponse(res, result, req); + }); + + router.patch( + '/features/:id', + asyncHandler(async (req, res) => { + if (typeof req.body?.enabled !== 'boolean') { + res + .status(400) + .json(fail('BAD_REQUEST', 'Body must include boolean "enabled"', extractRequestId(req))); + return; + } + const outcome = await updateFeature(req.params.id, req.body.enabled, extractRequestId(req)); + sendMutation(res, outcome); + }), + ); + + // ── Public config ───────────────────────────────────────────────────────── + + router.get( + /^\/public(\/.*)?$/, + (req, res) => { + const resourcePath = req.path.replace(/^\/public\/?/, ''); + const result = getPublicConfig(resourcePath, extractRequestId(req)); + sendCacheableResponse(res, result, req); + }, + ); + + router.patch( + /^\/public\/(.+)/, + asyncHandler(async (req, res) => { + const configKey = decodeURIComponent(req.path.replace(/^\/public\//, '')); + const outcome = await updatePublicConfig(configKey, req.body?.value, extractRequestId(req)); + res.status(200).json(outcome.response); + }), + ); + + return router; +} diff --git a/backend/subscription/store/__tests__/publicDataStore.test.ts b/backend/subscription/store/__tests__/publicDataStore.test.ts new file mode 100644 index 00000000..513ef959 --- /dev/null +++ b/backend/subscription/store/__tests__/publicDataStore.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { PublicDataStore } from '../publicDataStore'; + +describe('PublicDataStore', () => { + let store: PublicDataStore; + + beforeEach(() => { + store = new PublicDataStore(); + }); + + it('seeds default plans and pricing', () => { + expect(store.listPlans().length).toBeGreaterThan(0); + expect(store.listPricing().length).toBeGreaterThan(0); + }); + + it('updates and reads plans', () => { + const updated = store.updatePlan('basic', { price: 6.99 }); + expect(updated?.price).toBe(6.99); + expect(store.getPlan('basic')?.price).toBe(6.99); + }); + + it('returns null when updating unknown plan', () => { + expect(store.updatePlan('missing', { price: 1 })).toBeNull(); + }); + + it('updates public config', () => { + store.updatePublicConfig('app/version', { latest: '9.9.9' }); + expect(store.getPublicConfigEntry('app/version')).toEqual({ latest: '9.9.9' }); + }); + + it('stores feature overrides', () => { + store.setFeatureOverride('feat-a', false); + expect(store.getFeatureOverride('feat-a')).toBe(false); + }); + + it('reset restores defaults', () => { + store.updatePlan('basic', { price: 99 }); + store.reset(); + expect(store.getPlan('basic')?.price).toBe(4.99); + }); +}); diff --git a/backend/subscription/store/index.ts b/backend/subscription/store/index.ts new file mode 100644 index 00000000..cfc885a3 --- /dev/null +++ b/backend/subscription/store/index.ts @@ -0,0 +1 @@ +export { PublicDataStore, publicDataStore } from './publicDataStore'; diff --git a/backend/subscription/store/publicDataStore.ts b/backend/subscription/store/publicDataStore.ts new file mode 100644 index 00000000..19cec185 --- /dev/null +++ b/backend/subscription/store/publicDataStore.ts @@ -0,0 +1,112 @@ +/** + * In-memory store for public CDN-cacheable resources. + * Mutations write here so subsequent GETs reflect updates after CDN purge. + */ + +import type { PublicPlan } from '../controller/plansController'; +import type { PublicPricingTier } from '../controller/pricingController'; + +const DEFAULT_PLANS: PublicPlan[] = [ + { id: 'free', name: 'Free', price: 0, currency: 'USD', billingCycle: 'monthly' }, + { id: 'basic', name: 'Basic', price: 4.99, currency: 'USD', billingCycle: 'monthly' }, + { id: 'premium', name: 'Premium', price: 9.99, currency: 'USD', billingCycle: 'monthly' }, + { id: 'enterprise', name: 'Enterprise', price: 29.99, currency: 'USD', billingCycle: 'monthly' }, +]; + +const DEFAULT_PRICING: PublicPricingTier[] = [ + { planId: 'free', monthlyPrice: 0, yearlyPrice: 0, currency: 'USD' }, + { planId: 'basic', monthlyPrice: 4.99, yearlyPrice: 49.99, currency: 'USD', discountPercent: 17 }, + { planId: 'premium', monthlyPrice: 9.99, yearlyPrice: 99.99, currency: 'USD', discountPercent: 17 }, + { planId: 'enterprise', monthlyPrice: 29.99, yearlyPrice: 299.99, currency: 'USD', discountPercent: 17 }, +]; + +const DEFAULT_PUBLIC_CONFIG: Record = { + 'app/version': { minSupported: '1.0.0', latest: '1.0.0' }, + 'app/support': { email: 'support@subtrackr.app', docsUrl: 'https://docs.subtrackr.app' }, + 'billing/currencies': { supported: ['USD', 'EUR', 'GBP'] }, + 'onboarding/steps': { count: 4, skippable: true }, +}; + +export class PublicDataStore { + private plans = new Map(); + private pricing = new Map(); + private publicConfig = new Map(); + private featureOverrides = new Map(); + + constructor(seedDefaults = true) { + if (seedDefaults) { + this.reset(); + } + } + + reset(): void { + this.plans.clear(); + this.pricing.clear(); + this.publicConfig.clear(); + this.featureOverrides.clear(); + + for (const plan of DEFAULT_PLANS) { + this.plans.set(plan.id, { ...plan }); + } + for (const tier of DEFAULT_PRICING) { + this.pricing.set(tier.planId, { ...tier }); + } + for (const [key, value] of Object.entries(DEFAULT_PUBLIC_CONFIG)) { + this.publicConfig.set(key, JSON.parse(JSON.stringify(value))); + } + } + + listPlans(): PublicPlan[] { + return Array.from(this.plans.values()); + } + + getPlan(id: string): PublicPlan | undefined { + return this.plans.get(id); + } + + updatePlan(id: string, patch: Partial>): PublicPlan | null { + const existing = this.plans.get(id); + if (!existing) return null; + const updated = { ...existing, ...patch, id }; + this.plans.set(id, updated); + return updated; + } + + listPricing(): PublicPricingTier[] { + return Array.from(this.pricing.values()); + } + + getPricing(planId: string): PublicPricingTier | undefined { + return this.pricing.get(planId); + } + + updatePricing(planId: string, patch: Partial>): PublicPricingTier | null { + const existing = this.pricing.get(planId); + if (!existing) return null; + const updated = { ...existing, ...patch, planId }; + this.pricing.set(planId, updated); + return updated; + } + + listPublicConfig(): Record { + return Object.fromEntries(this.publicConfig.entries()); + } + + getPublicConfigEntry(key: string): unknown { + return this.publicConfig.has(key) ? this.publicConfig.get(key) : null; + } + + updatePublicConfig(key: string, value: unknown): void { + this.publicConfig.set(key, value); + } + + getFeatureOverride(featureId: string): boolean | undefined { + return this.featureOverrides.get(featureId); + } + + setFeatureOverride(featureId: string, enabled: boolean): void { + this.featureOverrides.set(featureId, enabled); + } +} + +export const publicDataStore = new PublicDataStore(); diff --git a/infra/fastly/snippets/fetch.vcl b/infra/fastly/snippets/fetch.vcl new file mode 100644 index 00000000..b267d4bf --- /dev/null +++ b/infra/fastly/snippets/fetch.vcl @@ -0,0 +1,10 @@ +# Inserted into vcl_fetch (type=fetch snippet) +if (beresp.http.Cache-Control ~ "s-maxage=") { + set beresp.ttl = std.atoi(regsub(beresp.http.Cache-Control, ".*s-maxage=([0-9]+).*", "\1")); +} + +if (beresp.http.Cache-Control ~ "stale-while-revalidate=") { + set beresp.stale_while_revalidate = std.atoi( + regsub(beresp.http.Cache-Control, ".*stale-while-revalidate=([0-9]+).*", "\1") + ); +} diff --git a/infra/fastly/snippets/recv.vcl b/infra/fastly/snippets/recv.vcl new file mode 100644 index 00000000..44829fb9 --- /dev/null +++ b/infra/fastly/snippets/recv.vcl @@ -0,0 +1,9 @@ +# Inserted into vcl_recv (type=recv snippet) +if (req.method == "GET" && ( + req.url ~ "^/plans/?(\\?.*)?$" || + req.url ~ "^/pricing/?(\\?.*)?$" || + req.url ~ "^/features/?(\\?.*)?$" || + req.url ~ "^/public(/.*)?(\\?.*)?$" +)) { + return (lookup); +} diff --git a/package-lock.json b/package-lock.json index 094f5626..11215aca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "expo-linking": "~7.1.7", "expo-notifications": "^0.31.5", "expo-status-bar": "~2.2.3", + "express": "^4.21.2", "graphql": "^16.13.2", "graphql-http": "^1.22.4", "i18next": "^26.0.8", @@ -72,9 +73,11 @@ "@testing-library/react-native": "13.3.3", "@typechain/ethers-v5": "^11.1.2", "@types/detox": "^17.14.3", + "@types/express": "^4.17.21", "@types/jest": "^29.5.14", "@types/react": "~19.2.14", "@types/react-dom": "^19.2.3", + "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "babel-plugin-module-resolver": "^5.0.2", @@ -93,6 +96,7 @@ "react-test-renderer": "^19.2.5", "semantic-release": "^24.2.9", "size-limit": "^11.1.4", + "supertest": "^7.0.0", "ts-jest": "^29.4.11", "tsx": "^4.20.3", "typechain": "^8.3.2", @@ -5577,6 +5581,16 @@ "integrity": "sha512-xSmezSupL+y9VkHZJGDoCBpmnB2ogM13ccaYDWqJTfS3dbuHkgjuwDFUmaFauBCboQMGB/S5UqUl2y54X99BmA==", "license": "MIT" }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -10457,6 +10471,17 @@ "@types/node": "*" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, "node_modules/@types/cacheable-request": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", @@ -10469,6 +10494,23 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/detox": { "version": "17.14.3", "resolved": "https://registry.npmjs.org/@types/detox/-/detox-17.14.3.tgz", @@ -10476,6 +10518,32 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -10498,6 +10566,13 @@ "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", "license": "MIT" }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -10567,6 +10642,20 @@ "integrity": "sha512-ssE3Vlrys7sdIzs5LOxCzTVMsU7i9oa/IaW92wF32JFb3CVczqOkru2xspuKczHEbG3nvmPY7IFqVmGGHdNbYw==", "license": "MIT" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.2.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz", @@ -10599,6 +10688,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -10645,6 +10748,39 @@ "@types/node": "*" } }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -10652,6 +10788,47 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.10", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.10.tgz", + "integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/superagent/node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -15151,6 +15328,16 @@ "dot-prop": "^5.1.0" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -15419,6 +15606,13 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-js-compat": { "version": "3.49.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", @@ -16481,6 +16675,17 @@ "node": ">=12" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", @@ -19086,6 +19291,13 @@ "node": ">=6" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-text-encoding": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz", @@ -19494,6 +19706,24 @@ "integrity": "sha512-EFRDrsMm/kyqbTQocNvRXMLjc7Es2Vk+IQFx/YW7hkUH1eBl4J1fqiP34l74Yt0pFLCNpc06fkbVk00008mzjg==", "license": "MIT" }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -20715,9 +20945,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -35647,6 +35877,82 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index 6a8afc44..86ac0394 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "typecheck": "tsc --noEmit", "test": "jest --passWithNoTests", "test:coverage": "jest --coverage", + "api:start": "npx --yes tsx backend/server/start.ts", + "test:cdn": "jest -c jest.backend.config.js backend/shared/middleware/__tests__/cacheHeaders.test.ts backend/shared/cache/__tests__/ backend/subscription/controller/__tests__/controllers.test.ts backend/subscription/router/__tests__/publicApiRouter.test.ts backend/subscription/store/__tests__/publicDataStore.test.ts", "performance:ci": "node scripts/check-performance-budget.js", "performance:benchmark": "node scripts/check-performance-budget.js", "mutation:test": "npx --yes @stryker-mutator/core@9.0.0 run", @@ -112,6 +114,7 @@ "react-native-safe-area-context": "5.7.0", "react-native-screens": "~4.24.0", "react-native-svg": "15.15.4", + "express": "^4.21.2", "zod": "^3.23.8", "zustand": "^5.0.0" }, @@ -134,9 +137,11 @@ "@testing-library/react-native": "13.3.3", "@typechain/ethers-v5": "^11.1.2", "@types/detox": "^17.14.3", + "@types/express": "^4.17.21", "@types/jest": "^29.5.14", "@types/react": "~19.2.14", "@types/react-dom": "^19.2.3", + "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "babel-plugin-module-resolver": "^5.0.2", @@ -155,6 +160,7 @@ "react-test-renderer": "^19.2.5", "semantic-release": "^24.2.9", "size-limit": "^11.1.4", + "supertest": "^7.0.0", "ts-jest": "^29.4.11", "tsx": "^4.20.3", "typechain": "^8.3.2", diff --git a/scripts/deploy-fastly-vcl.sh b/scripts/deploy-fastly-vcl.sh new file mode 100644 index 00000000..81245587 --- /dev/null +++ b/scripts/deploy-fastly-vcl.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Deploy Fastly recv/fetch snippets and activate the new service version. +set -euo pipefail + +SERVICE_ID="${FASTLY_SERVICE_ID:?FASTLY_SERVICE_ID required}" +API_TOKEN="${FASTLY_API_TOKEN:?FASTLY_API_TOKEN required}" +SNIPPETS_DIR="${1:-infra/fastly/snippets}" + +if [ ! -d "$SNIPPETS_DIR" ]; then + echo "Snippets directory not found: $SNIPPETS_DIR" + exit 1 +fi + +API="https://api.fastly.com" +AUTH=(-H "Fastly-Key: ${API_TOKEN}" -H "Accept: application/json") + +upload_snippet() { + local version="$1" + local name="$2" + local type="$3" + local file="$4" + local content + content=$(cat "$file") + + echo "Uploading snippet ${name} (${type}) to version ${version}..." + curl -sf -X PUT \ + "${API}/service/${SERVICE_ID}/version/${version}/snippet/${name}" \ + -H "Fastly-Key: ${API_TOKEN}" \ + --data-urlencode "content=${content}" \ + --data-urlencode "type=${type}" \ + --data-urlencode "priority=100" \ + --data-urlencode "dynamic=0" +} + +echo "Cloning active service version..." +CLONE_RESPONSE=$(curl -sf -X POST "${API}/service/${SERVICE_ID}/version" "${AUTH[@]}") +VERSION=$(echo "$CLONE_RESPONSE" | python3 -c 'import json,sys; print(json.load(sys.stdin)["number"])') +echo "Created version ${VERSION}" + +upload_snippet "$VERSION" "subtrackr_cache_recv" "recv" "${SNIPPETS_DIR}/recv.vcl" +upload_snippet "$VERSION" "subtrackr_cache_fetch" "fetch" "${SNIPPETS_DIR}/fetch.vcl" + +echo "Activating version ${VERSION}..." +curl -sf -X PUT "${API}/service/${SERVICE_ID}/version/${VERSION}/activate" "${AUTH[@]}" > /dev/null + +echo "Fastly cache snippets deployed and activated (version ${VERSION})" diff --git a/src/store/_tests_/transactionQueueStore.test.ts b/src/store/_tests_/transactionQueueStore.test.ts index 48b00ba4..b4c491b1 100644 --- a/src/store/_tests_/transactionQueueStore.test.ts +++ b/src/store/_tests_/transactionQueueStore.test.ts @@ -3,7 +3,10 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals'; import { ExecuteOrQueueResult, useTransactionQueueStore } from '../transactionQueueStore'; -const mockCreateSuperfluidStream = jest.fn, unknown[]>(); +const mockCreateSuperfluidStream = jest.fn< + Promise<{ streamId: string; txHash: string }>, + unknown[] +>(); const mockCreateSablierStream = jest.fn(); jest.mock('@react-native-async-storage/async-storage', () => ({