Skip to content
Open
26 changes: 26 additions & 0 deletions identity-webhook/balance-gate.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type {
BalanceCheck,
BalanceCheckContext,
UsageIdentity,
} from "./protocol.js";

export function parseUsdMicros(
value: bigint | number | string | null | undefined,
): bigint | null;

export function createBalanceGate(options: {
getBalanceUsdMicros: (
identity: UsageIdentity,
ctx: BalanceCheckContext,
) =>
| Promise<bigint | number | string | null | undefined>
| bigint
| number
| string
| null
| undefined;
minBalanceUsdMicros?: bigint | number | string;
reauthTtlSeconds?: number;
failClosed?: boolean;
onError?: (err: unknown, identity: UsageIdentity) => void;
}): BalanceCheck;
144 changes: 144 additions & 0 deletions identity-webhook/balance-gate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* Live balance/credit gate for the identity webhook.
*
* `createBalanceGate` turns a simple per-identity balance lookup into a
* `checkBalance` hook for `handleAuthorize` (protocol.mjs). It is verifier
* agnostic: whatever proved the identity (OIDC JWT, composite API key, or a
* plain API key), the gate reads the caller-supplied balance and rejects with
* the go-livepeer wire status 483 (`insufficient_balance`) when the customer is
* out of credit — closing the "still streaming after credit hits zero" gap that
* a mint-time-only gate leaves open.
*
* Balances are USD micros (1 USD = 1_000_000 micros), accepted as bigint,
* integer number, or integer string.
*
* Example:
* import { handleAuthorize } from "@livepeer/clearinghouse-identity-webhook/protocol";
* import { createBalanceGate } from "@livepeer/clearinghouse-identity-webhook/balance-gate";
*
Comment thread
eliteprox marked this conversation as resolved.
Outdated
* const checkBalance = createBalanceGate({
* getBalanceUsdMicros: async (identity) =>
* readLiveCreditBalanceUsdMicros(identity.client_id, identity.usage_subject),
* reauthTtlSeconds: 30, // re-check at least every 30s mid-stream
* });
* return handleAuthorize(request, { webhookSecret, endUserAuth, checkBalance });
*/
import {
REMOTE_SIGNER_ERROR_CODE,
REMOTE_SIGNER_HTTP_STATUS,
WebhookError,
} from "./protocol.mjs";

function nowSeconds() {
return Math.floor(Date.now() / 1000);
}

/**
* Coerce a USD-micros balance (bigint | integer number | integer string) to a
* bigint. Returns null for anything non-integer (including "1.5", "", null).
*/
export function parseUsdMicros(value) {
if (typeof value === "bigint") {
return value;
}
if (typeof value === "number") {
return Number.isInteger(value) ? BigInt(value) : null;
}
Comment thread
Copilot marked this conversation as resolved.
if (typeof value === "string") {
const trimmed = value.trim();
if (!/^-?\d+$/.test(trimmed)) {
return null;
}
try {
return BigInt(trimmed);
} catch {
return null;
}
}
return null;
}

/**
* Build a `checkBalance` hook from a balance lookup.
*
* @param {object} options
* @param {(identity: import("./protocol.js").UsageIdentity, ctx: import("./protocol.js").BalanceCheckContext) => any} options.getBalanceUsdMicros
* Resolve remaining balance (USD micros) for the identity. May be async.
Comment thread
Copilot marked this conversation as resolved.
Comment thread
eliteprox marked this conversation as resolved.
* Return null/undefined to signal "balance unknown" (see failClosed).
* @param {bigint | number | string} [options.minBalanceUsdMicros=1]
* Minimum balance required to authorize. Default: 1 micro (any positive credit).
* @param {number} [options.reauthTtlSeconds]
* When set, caps the returned expiry to now + this, forcing go-livepeer to
* call back and re-check the balance at least this often.
* @param {boolean} [options.failClosed=true]
* On lookup error or unknown balance: true → reject 503 billing_unavailable;
* false → allow (fail open).
* @param {(err: unknown, identity: import("./protocol.js").UsageIdentity) => void} [options.onError]
* Optional hook to observe lookup errors / unparseable balances.
* @returns {import("./protocol.js").BalanceCheck}
Comment thread
Copilot marked this conversation as resolved.
Outdated
*/
Comment thread
eliteprox marked this conversation as resolved.
export function createBalanceGate({
getBalanceUsdMicros,
minBalanceUsdMicros = 1n,
reauthTtlSeconds,
failClosed = true,
onError,
} = {}) {
if (typeof getBalanceUsdMicros !== "function") {
throw new TypeError("createBalanceGate: getBalanceUsdMicros is required");
}
const minBalance = parseUsdMicros(minBalanceUsdMicros);
if (minBalance === null) {
throw new TypeError("createBalanceGate: minBalanceUsdMicros must be an integer");
}
Comment thread
eliteprox marked this conversation as resolved.
let ttl = null;
if (reauthTtlSeconds != null) {
ttl = Number(reauthTtlSeconds);
if (!Number.isFinite(ttl) || ttl <= 0) {
throw new TypeError("createBalanceGate: reauthTtlSeconds must be a positive number");
}
}
Comment thread
eliteprox marked this conversation as resolved.
Outdated

const billingUnavailable = (message) =>
new WebhookError(message, {
status: REMOTE_SIGNER_HTTP_STATUS.BILLING_UNAVAILABLE,
code: REMOTE_SIGNER_ERROR_CODE.BILLING_UNAVAILABLE,
});

return async function checkBalance(ctx) {
let rawBalance;
try {
rawBalance = await getBalanceUsdMicros(ctx.identity, ctx);
} catch (err) {
onError?.(err, ctx.identity);
if (failClosed) {
throw billingUnavailable("billing balance lookup failed");
}
return undefined;
}

const balance = parseUsdMicros(rawBalance);
if (balance === null) {
onError?.(
new Error(`balance is not an integer micros value: ${String(rawBalance)}`),
ctx.identity,
);
if (failClosed) {
throw billingUnavailable("billing balance unavailable");
}
return undefined;
}

if (balance < minBalance) {
throw new WebhookError("insufficient balance", {
status: REMOTE_SIGNER_HTTP_STATUS.INSUFFICIENT_BALANCE,
code: REMOTE_SIGNER_ERROR_CODE.INSUFFICIENT_BALANCE,
});
}

if (ttl !== null) {
return { expiry: nowSeconds() + ttl };
}
return undefined;
};
}
134 changes: 134 additions & 0 deletions identity-webhook/balance-gate.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createBalanceGate, parseUsdMicros } from "./balance-gate.mjs";
import { WebhookError } from "./protocol.mjs";

const identity = {
issuer: "http://webhook.test",
client_id: "tenant-a",
usage_subject: "user-1",
usage_subject_type: "external_user_id",
};

function ctx(overrides = {}) {
return { identity, expiry: 2000000000, payload: {}, request: new Request("http://x/authorize"), ...overrides };
}

describe("parseUsdMicros", () => {
it("accepts bigint, integer number, and integer string", () => {
assert.equal(parseUsdMicros(5n), 5n);
assert.equal(parseUsdMicros(5), 5n);
assert.equal(parseUsdMicros("5"), 5n);
assert.equal(parseUsdMicros(" 42 "), 42n);
assert.equal(parseUsdMicros("0"), 0n);
assert.equal(parseUsdMicros("-3"), -3n);
});

it("rejects non-integer / junk values as null", () => {
assert.equal(parseUsdMicros(null), null);
assert.equal(parseUsdMicros(undefined), null);
assert.equal(parseUsdMicros(""), null);
assert.equal(parseUsdMicros("1.5"), null);
assert.equal(parseUsdMicros("abc"), null);
assert.equal(parseUsdMicros(1.5), null);
});
});

describe("createBalanceGate", () => {
it("throws on missing getBalanceUsdMicros", () => {
assert.throws(() => createBalanceGate({}), /getBalanceUsdMicros is required/);
});

it("validates minBalanceUsdMicros and reauthTtlSeconds", () => {
assert.throws(
() => createBalanceGate({ getBalanceUsdMicros: async () => 0n, minBalanceUsdMicros: "x" }),
/minBalanceUsdMicros must be an integer/,
);
assert.throws(
() => createBalanceGate({ getBalanceUsdMicros: async () => 0n, reauthTtlSeconds: 0 }),
/reauthTtlSeconds must be a positive number/,
);
});

it("allows a positive balance", async () => {
const gate = createBalanceGate({ getBalanceUsdMicros: async () => "1" });
assert.equal(await gate(ctx()), undefined);
});

it("rejects zero balance with 483 insufficient_balance", async () => {
const gate = createBalanceGate({ getBalanceUsdMicros: async () => 0n });
await assert.rejects(gate(ctx()), (err) => {
assert.ok(err instanceof WebhookError);
assert.equal(err.status, 483);
assert.equal(err.code, "insufficient_balance");
return true;
});
});

it("rejects a negative balance", async () => {
const gate = createBalanceGate({ getBalanceUsdMicros: async () => "-100" });
await assert.rejects(gate(ctx()), /insufficient balance/);
});

it("honors a custom minBalanceUsdMicros threshold", async () => {
const gate = createBalanceGate({
getBalanceUsdMicros: async () => 500n,
minBalanceUsdMicros: 1000n,
});
await assert.rejects(gate(ctx()), (err) => err.status === 483);
});

it("passes identity to the lookup", async () => {
let seen;
const gate = createBalanceGate({
getBalanceUsdMicros: async (id) => {
seen = id;
return 10n;
},
});
await gate(ctx());
assert.equal(seen.client_id, "tenant-a");
assert.equal(seen.usage_subject, "user-1");
});

it("caps expiry via reauthTtlSeconds", async () => {
const gate = createBalanceGate({
getBalanceUsdMicros: async () => 10n,
reauthTtlSeconds: 30,
});
const before = Math.floor(Date.now() / 1000);
const result = await gate(ctx());
assert.ok(result.expiry >= before + 30 && result.expiry <= before + 31);
});

it("fails closed with 503 on lookup error by default", async () => {
const errors = [];
const gate = createBalanceGate({
getBalanceUsdMicros: async () => {
throw new Error("openmeter down");
},
onError: (err) => errors.push(err),
});
await assert.rejects(gate(ctx()), (err) => {
assert.equal(err.status, 503);
assert.equal(err.code, "billing_unavailable");
return true;
});
assert.equal(errors.length, 1);
});

it("fails open when failClosed is false", async () => {
const gate = createBalanceGate({
getBalanceUsdMicros: async () => {
throw new Error("openmeter down");
},
failClosed: false,
});
assert.equal(await gate(ctx()), undefined);
});

it("treats an unparseable balance as billing_unavailable when failing closed", async () => {
const gate = createBalanceGate({ getBalanceUsdMicros: async () => "not-a-number" });
await assert.rejects(gate(ctx()), (err) => err.status === 503);
});
});
Loading