-
Notifications
You must be signed in to change notification settings - Fork 2
feat(identity-webhook): live balance gate after identity verify #64
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
eliteprox
wants to merge
8
commits into
main
Choose a base branch
from
feat/balance-gate
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 3 commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
ff6f4a6
feat(identity-webhook): add live balance gate after identity verify
eliteprox b323083
test(identity-webhook): e2e authorize with composite and balance gate…
eliteprox b544587
fix(identity-webhook): surface exchange allowance failures (#66)
eliteprox 4cb0126
feat(identity-webhook): wire optional demo balance gate in compose se…
eliteprox 56a7c0f
fix(identity-webhook): address Copilot review on balance gate
eliteprox 21683e5
fix(identity-webhook): reject negative minBalance and validate demo T…
eliteprox dca2714
refactor(identity-webhook): rename reauthTtlSeconds to expiryTtlSeconds
eliteprox 31dd346
fix(identity-webhook): validate demo balance and model expiry TTL
eliteprox File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"; | ||
| * | ||
| * 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; | ||
| } | ||
|
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. | ||
|
Copilot marked this conversation as resolved.
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} | ||
|
Copilot marked this conversation as resolved.
Outdated
|
||
| */ | ||
|
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"); | ||
| } | ||
|
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"); | ||
| } | ||
| } | ||
|
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; | ||
| }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.