mirror of
https://github.com/hanzoai/chat.git
synced 2026-09-30 20:51:17 +00:00
The credits row was missing for a funded account. The balance is real --
Commerce holds $149,863.17 for the hanzo org -- and chat could never read it:
CommerceClient scoped its service-token call with `X-Hanzo-Org`, which
Commerce does not read on that path. Every call answered 401 "sign in to view
billing", byte-identical to sending no credential at all. The header is
`X-Org-Id`, as `api/billing/me.go` documents and as the LLM gate in
hanzoai/ai has always sent.
Proven against the live service from inside the pod, same token, same URL:
X-Hanzo-Org -> 401, X-Org-Id -> 200 {"available":14986317}.
Nothing caught this because three deliberate fallbacks sit behind it, each
doing exactly what it was written to do: checkBalance fails CLOSED and throws,
the balance controller falls through to the local tokenCredits ledger,
production does not fund that ledger (balance.enabled=false), the read 404s,
and the client renders no balance rather than a wrong one. A wrong header name
came out the far end as silence.
So the header is pinned where it is written, including the spelling that
failed, and the refusal case asserts a THROW rather than a zero -- a 401 must
never read as "this account has no money". Reverting the header turns 3 of the
5 red.
`ask.js` and CloudAgentsClient already sent `X-Org-Id`; this was the one
caller that did not.
Co-authored-by: Hanzo Dev <[email protected]>
123 lines
4.9 KiB
JavaScript
123 lines
4.9 KiB
JavaScript
// Money-gate decision matrix for the Commerce-first, FAIL-CLOSED balance gate
|
|
// (per-org via service token + X-Org-Id; the cloud gateway debits the user's
|
|
// own hk- key against the SAME org). Proves both directions: unmetered AI never
|
|
// leaks (block on any ambiguity), and funded users + capped guests are never
|
|
// wrongly blocked.
|
|
|
|
const mockClient = {
|
|
checkBalance: jest.fn(),
|
|
isModelAllowed: jest.fn(),
|
|
};
|
|
|
|
jest.mock('@hanzochat/data-schemas', () => ({
|
|
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
|
|
}));
|
|
jest.mock('~/server/services/CommerceClient', () => ({
|
|
getCommerceClient: jest.fn(() => mockClient),
|
|
}));
|
|
jest.mock('~/cache', () => ({ logViolation: jest.fn().mockResolvedValue(undefined) }));
|
|
jest.mock('./tx', () => ({ getMultiplier: () => 1 }));
|
|
jest.mock('./Transaction', () => ({ createAutoRefillTransaction: jest.fn() }));
|
|
jest.mock('~/db/models', () => ({ Balance: { findOne: jest.fn() } }));
|
|
jest.mock('@hanzochat/data-provider', () => ({
|
|
ViolationTypes: { TOKEN_BALANCE: 'token_balance' },
|
|
}));
|
|
|
|
const { checkBalance } = require('./balanceMethods');
|
|
const { getCommerceClient } = require('~/server/services/CommerceClient');
|
|
const { Balance } = require('~/db/models');
|
|
|
|
const argsFor = (user) => ({
|
|
req: { user },
|
|
res: {},
|
|
txData: { user: user?.id ?? 'u1', tokenType: 'prompt', amount: 100, model: 'zen4-mini' },
|
|
});
|
|
|
|
// checkBalance resolves `true` when allowed, throws a JSON error (carrying
|
|
// `reason`) when blocked.
|
|
const decide = async (user = { id: 'u1', organization: 'acme' }) => {
|
|
try {
|
|
const ok = await checkBalance(argsFor(user));
|
|
return { allowed: ok, reason: null };
|
|
} catch (err) {
|
|
let reason = null;
|
|
try {
|
|
reason = JSON.parse(err.message).reason;
|
|
} catch (_) {
|
|
reason = err.message;
|
|
}
|
|
return { allowed: false, reason };
|
|
}
|
|
};
|
|
|
|
describe('checkBalance — Commerce-first money gate (fail closed)', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
getCommerceClient.mockReturnValue(mockClient);
|
|
mockClient.isModelAllowed.mockResolvedValue({ allowed: true, tier: 't', allowedModels: ['*'] });
|
|
process.env.HANZO_MIN_BALANCE = '1.00'; // 100 cents minimum
|
|
});
|
|
|
|
it('ALLOWS a guest WITHOUT consulting Commerce (capped by IP limiter + guest key)', async () => {
|
|
const { allowed } = await decide({ id: 'g1', guest: true });
|
|
expect(allowed).toBe(true);
|
|
expect(mockClient.checkBalance).not.toHaveBeenCalled();
|
|
expect(Balance.findOne).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ALLOWS a funded org (available >= min)', async () => {
|
|
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 500 });
|
|
const { allowed, reason } = await decide();
|
|
expect(allowed).toBe(true);
|
|
expect(reason).toBeNull();
|
|
expect(mockClient.checkBalance).toHaveBeenCalledWith('acme');
|
|
});
|
|
|
|
it('BLOCKS commerce_insufficient when balance below minimum ($0 → claim $5)', async () => {
|
|
mockClient.checkBalance.mockResolvedValue({ sufficient: false, available: 0 });
|
|
const { allowed, reason } = await decide();
|
|
expect(allowed).toBe(false);
|
|
expect(reason).toBe('commerce_insufficient');
|
|
});
|
|
|
|
it('BLOCKS commerce_insufficient when balance is a tiny non-zero below min', async () => {
|
|
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 50 }); // $0.50 < $1
|
|
const { allowed, reason } = await decide();
|
|
expect(allowed).toBe(false);
|
|
expect(reason).toBe('commerce_insufficient');
|
|
});
|
|
|
|
it('FAILS CLOSED (commerce_unavailable) when Commerce throws — never bleeds', async () => {
|
|
mockClient.checkBalance.mockRejectedValue(new Error('ECONNREFUSED'));
|
|
const { allowed, reason } = await decide();
|
|
expect(allowed).toBe(false);
|
|
expect(reason).toBe('commerce_unavailable');
|
|
});
|
|
|
|
it('BLOCKS model_not_allowed for a funded org whose tier forbids the model', async () => {
|
|
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 500 });
|
|
mockClient.isModelAllowed.mockResolvedValue({
|
|
allowed: false,
|
|
tier: 'free',
|
|
allowedModels: ['zen3-nano'],
|
|
});
|
|
const { allowed, reason } = await decide();
|
|
expect(allowed).toBe(false);
|
|
expect(reason).toBe('model_not_allowed');
|
|
});
|
|
|
|
it('ALLOWS a funded org even if the tier check itself errors (fail-open tier, balance ok)', async () => {
|
|
mockClient.checkBalance.mockResolvedValue({ sufficient: true, available: 500 });
|
|
mockClient.isModelAllowed.mockRejectedValue(new Error('tier-check 500'));
|
|
const { allowed } = await decide();
|
|
expect(allowed).toBe(true);
|
|
});
|
|
|
|
it('FAILS CLOSED for an authed user with NO billing org (no commerce branch, no local credits)', async () => {
|
|
Balance.findOne.mockReturnValue({ lean: () => Promise.resolve(null) });
|
|
const { allowed } = await decide({ id: 'u2', organization: '' });
|
|
expect(allowed).toBe(false);
|
|
expect(mockClient.checkBalance).not.toHaveBeenCalled();
|
|
});
|
|
});
|