diff --git a/.gitignore b/.gitignore index dd3225a..245bf32 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ _cgo_export.* _obj/ _test/ .gocache/ +.worktrees/ # ─── Node / TypeScript (sdk, cli) ───────────────────────────────────────────── **/node_modules/ diff --git a/gateway/README.md b/gateway/README.md index 4243824..eefd239 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -125,7 +125,7 @@ All configuration is via CLI flags or `REP_GATEWAY_*` environment variables. Fla | `--log-level` | `REP_GATEWAY_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` | | `--allowed-origins` | `REP_GATEWAY_ALLOWED_ORIGINS` | (empty) | CORS origins for session key endpoint | | `--session-key-ttl` | `REP_GATEWAY_SESSION_KEY_TTL` | `30s` | Session key time-to-live | -| `--session-key-max-rate` | `REP_GATEWAY_SESSION_KEY_MAX_RATE` | `10` | Max session key requests/min/IP | +| `--session-key-max-rate` | `REP_GATEWAY_SESSION_KEY_MAX_RATE` | `15` | Max session key requests/min/IP | | `--health-port` | `REP_GATEWAY_HEALTH_PORT` | `0` | Separate health check port (0 = same) | | `--version` | — | — | Print version and exit | diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go index 15600c4..f6c2976 100644 --- a/gateway/internal/config/config.go +++ b/gateway/internal/config/config.go @@ -115,7 +115,7 @@ func Parse(args []string, version string) (*Config, error) { defaultHotReloadMode := "signal" defaultPollInterval := "30s" defaultSessionTTL := "30s" - defaultSessionMaxRate := 10 + defaultSessionMaxRate := 15 defaultStrict := false var defaultAllowedOrigins string diff --git a/gateway/internal/config/config_test.go b/gateway/internal/config/config_test.go index e0fa6ef..72eb1bd 100644 --- a/gateway/internal/config/config_test.go +++ b/gateway/internal/config/config_test.go @@ -29,8 +29,8 @@ func TestParse_Defaults(t *testing.T) { if cfg.LogFormat != "json" { t.Errorf("expected log-format=json, got %s", cfg.LogFormat) } - if cfg.SessionKeyMaxRate != 10 { - t.Errorf("expected session-key-max-rate=10, got %d", cfg.SessionKeyMaxRate) + if cfg.SessionKeyMaxRate != 15 { + t.Errorf("expected session-key-max-rate=15, got %d", cfg.SessionKeyMaxRate) } } diff --git a/gateway/internal/manifest/manifest.go b/gateway/internal/manifest/manifest.go index 13fd35e..56c0d63 100644 --- a/gateway/internal/manifest/manifest.go +++ b/gateway/internal/manifest/manifest.go @@ -458,7 +458,7 @@ func defaultSettings() *Settings { HotReloadMode: "signal", HotReloadPollInterval: 30 * time.Second, SessionKeyTTL: 30 * time.Second, - SessionKeyMaxRate: 10, + SessionKeyMaxRate: 15, } } diff --git a/sdk/src/__tests__/index.test.ts b/sdk/src/__tests__/index.test.ts index ff6c192..be25619 100644 --- a/sdk/src/__tests__/index.test.ts +++ b/sdk/src/__tests__/index.test.ts @@ -188,6 +188,49 @@ describe('getSecure()', () => { const { getSecure } = await import('../index'); await expect(getSecure('KEY')).rejects.toThrow('500'); }); + + it('coalesces concurrent session key requests', async () => { + injectPayload( + makePayload({}, { sensitive: 'dGVzdA==', keyEndpoint: '/rep/session-key' }) + ); + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + }); + vi.stubGlobal('fetch', fetchMock); + + const { getSecure } = await import('../index'); + + await Promise.allSettled([getSecure('A'), getSecure('B'), getSecure('C')]); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('retries after a failed coalesced request', async () => { + injectPayload( + makePayload({}, { sensitive: 'dGVzdA==', keyEndpoint: '/rep/session-key' }) + ); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + }) + .mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + }); + vi.stubGlobal('fetch', fetchMock); + + const { getSecure } = await import('../index'); + + await expect(Promise.all([getSecure('A'), getSecure('B')])).rejects.toThrow('429'); + await expect(getSecure('A')).rejects.toThrow('500'); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); }); // ─── onChange() ────────────────────────────────────────────────────────────── diff --git a/sdk/src/index.ts b/sdk/src/index.ts index fa81080..e126d30 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -60,6 +60,7 @@ let _publicVars: Readonly> = Object.freeze({}); // Cache for decrypted sensitive variables (in-memory only, never persisted). let _sensitiveCache: Record | null = null; +let _sensitiveLoadPromise: Promise> | null = null; // Hot reload state. let _eventSource: EventSource | null = null; @@ -187,56 +188,73 @@ export async function getSecure(key: string): Promise { return _sensitiveCache[key]; } - // Fetch session key from the gateway. - const resp = await fetch(_payload._meta.key_endpoint); - if (!resp.ok) { - throw new REPError(`Session key request failed: ${resp.status} ${resp.statusText}`); + if (!_sensitiveLoadPromise) { + _sensitiveLoadPromise = _loadSensitiveVars(); } - const sessionKey: SessionKeyResponse = await resp.json(); + const sensitiveMap = await _sensitiveLoadPromise; - // Decode the encryption key. - const rawKey = Uint8Array.from(atob(sessionKey.key), (c) => c.charCodeAt(0)); + if (!(key in sensitiveMap)) { + throw new REPError(`SENSITIVE variable "${key}" not found in payload.`); + } - // Decode the encrypted blob. - const blobBytes = Uint8Array.from(atob(_payload.sensitive), (c) => c.charCodeAt(0)); + return sensitiveMap[key]; +} + +async function _loadSensitiveVars(): Promise> { + try { + if (!_payload || !_payload.sensitive || !_payload._meta.key_endpoint) { + throw new REPError('No SENSITIVE tier variables in payload.'); + } - // Extract nonce (first 12 bytes) and ciphertext+tag (rest). - const nonce = blobBytes.slice(0, 12); - const ciphertext = blobBytes.slice(12); + // Fetch session key from the gateway. + const resp = await fetch(_payload._meta.key_endpoint); + if (!resp.ok) { + throw new REPError(`Session key request failed: ${resp.status} ${resp.statusText}`); + } - // Decrypt using Web Crypto API (AES-256-GCM). - const cryptoKey = await crypto.subtle.importKey( - 'raw', - rawKey, - { name: 'AES-GCM' }, - false, - ['decrypt'] - ); + const sessionKey: SessionKeyResponse = await resp.json(); - // The AAD is the preliminary integrity token (see payload builder). - // For simplicity in the SDK, we use the integrity from _meta. - // This matches the gateway's encryption AAD. - const encoder = new TextEncoder(); - const aad = encoder.encode(_payload._meta.integrity); + // Decode the encryption key. + const rawKey = Uint8Array.from(atob(sessionKey.key), (c) => c.charCodeAt(0)); - const plaintext = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv: nonce, additionalData: aad }, - cryptoKey, - ciphertext - ); + // Decode the encrypted blob. + const blobBytes = Uint8Array.from(atob(_payload.sensitive), (c) => c.charCodeAt(0)); - const decoder = new TextDecoder(); - const sensitiveMap: Record = JSON.parse(decoder.decode(plaintext)); + // Extract nonce (first 12 bytes) and ciphertext+tag (rest). + const nonce = blobBytes.slice(0, 12); + const ciphertext = blobBytes.slice(12); - // Cache all decrypted values. - _sensitiveCache = sensitiveMap; + // Decrypt using Web Crypto API (AES-256-GCM). + const cryptoKey = await crypto.subtle.importKey( + 'raw', + rawKey, + { name: 'AES-GCM' }, + false, + ['decrypt'] + ); - if (!(key in sensitiveMap)) { - throw new REPError(`SENSITIVE variable "${key}" not found in payload.`); + // The AAD is the preliminary integrity token (see payload builder). + // For simplicity in the SDK, we use the integrity from _meta. + // This matches the gateway's encryption AAD. + const encoder = new TextEncoder(); + const aad = encoder.encode(_payload._meta.integrity); + + const plaintext = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: nonce, additionalData: aad }, + cryptoKey, + ciphertext + ); + + const decoder = new TextDecoder(); + const sensitiveMap: Record = JSON.parse(decoder.decode(plaintext)); + + // Cache all decrypted values. + _sensitiveCache = sensitiveMap; + return sensitiveMap; + } finally { + _sensitiveLoadPromise = null; } - - return sensitiveMap[key]; } /**