All files / realm/blong-gateway/meter script.ts

100% Statements 78/78
14.28% Branches 1/7
100% Functions 1/1
100% Lines 78/78

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 791x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x  
// Atomic metering script (rate-limit + credit deduction) executed via EVAL on
// the Redis adapter.
//
// All keys carry the same `{app:<id>}` hash tag, so the script always runs on a
// single cluster slot and is atomic with respect to concurrent requests from
// the same application.
//
// KEYS[1] = {app:<id>}:cfg            HASH {baseMonthlyCredits, rateLimit, rateWindowSec}
// KEYS[2] = {app:<id>}:credits:YYYY-MM HASH {balance, base, updatedAt}
// KEYS[3] = {app:<id>}:rate:<window>   STRING counter (fixed window, TTL)
//
// ARGV[1] = creditCost  ARGV[2] = now (epoch seconds)  ARGV[3] = rateWindowSec
//
// Returns an array: {allowed, reason, creditsRemaining, rateCount, rateLimit, ttl}
 
export const meterScript = `
local cfgKey  = KEYS[1]
local credKey = KEYS[2]
local rateKey = KEYS[3]
 
local cost   = tonumber(ARGV[1])
local now    = tonumber(ARGV[2])
local window = tonumber(ARGV[3])
 
-- 1) rate limit (fixed window, auto-create with TTL)
local limit = tonumber(redis.call('HGET', cfgKey, 'rateLimit') or '0')
local count = redis.call('INCR', rateKey)
if count == 1 then redis.call('EXPIRE', rateKey, window) end
if limit > 0 and count > limit then
    return {0, 'rate', -1, count, limit, redis.call('TTL', rateKey)}
end
 
-- 2) credits (monthly bucket, deterministic auto-init from cfg.base on the
--    first request of a new month)
local base = tonumber(redis.call('HGET', cfgKey, 'baseMonthlyCredits') or '0')
local bal = redis.call('HGET', credKey, 'balance')
if not bal then
    redis.call('HSET', credKey, 'balance', base)
    redis.call('HSET', credKey, 'base', base)
    redis.call('HSET', credKey, 'updatedAt', now)
    bal = base
end
bal = tonumber(bal)
if bal < cost then
    return {0, 'credits', bal, count, limit, redis.call('TTL', rateKey)}
end
redis.call('HINCRBY', credKey, 'balance', -cost)
return {1, 'ok', bal - cost, count, limit, redis.call('TTL', rateKey)}
`.trim();
 
export type MeterReason = 'ok' | 'rate' | 'credits';
 
export interface MeterResult {
    allowed: boolean;
    reason: MeterReason;
    creditsRemaining: number;
    rateCount: number;
    rateLimit: number;
    ttl: number;
}
 
/**
 * Parse the Lua response array into a structured MeterResult.
 * A non-array / unexpected response is treated as a deny (fail-closed).
 */
export function parseMeterResult(raw: unknown): MeterResult {
    const values = Array.isArray(raw) ? raw : [];
    const [allowed, reason, creditsRemaining, rateCount, rateLimit, ttl] =
        values as unknown[];
    return {
        allowed: allowed === 1,
        reason: (reason as MeterReason) || 'ok',
        creditsRemaining: Number(creditsRemaining ?? -1),
        rateCount: Number(rateCount ?? -1),
        rateLimit: Number(rateLimit ?? -1),
        ttl: Number(ttl ?? -1),
    };
}