Skip to content

Commit 1923fe7

Browse files
fix(custom-blocks): stop forwarding the publisher's personal quota to consumers
1 parent de8eddd commit 1923fe7

2 files changed

Lines changed: 64 additions & 11 deletions

File tree

apps/sim/lib/workflows/custom-blocks/child-execution.test.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,17 +51,49 @@ describe('admitCustomBlockChildExecution', () => {
5151
await expect(admitCustomBlockChildExecution(attribution)).resolves.toBeUndefined()
5252
})
5353

54-
it('throws with the payer message when headroom is exhausted', async () => {
54+
it('forwards the payer-scoped message, which describes the shared org', async () => {
5555
mockCheckAttributedUsageLimits.mockResolvedValue({
5656
isExceeded: true,
57-
message: 'Organization usage limit exceeded',
57+
scope: 'payer',
58+
message: 'Organization usage limit exceeded: $50.00 pooled of $50.00 organization limit.',
5859
})
5960

60-
await expect(admitCustomBlockChildExecution(attribution)).rejects.toBeInstanceOf(
61-
CustomBlockAdmissionError
61+
await expect(admitCustomBlockChildExecution(attribution)).rejects.toThrow(
62+
'Organization usage limit exceeded: $50.00 pooled of $50.00 organization limit.'
6263
)
6364
})
6465

66+
it("never forwards the owner's personal member-cap message to a consumer", async () => {
67+
mockCheckAttributedUsageLimits.mockResolvedValue({
68+
isExceeded: true,
69+
scope: 'member',
70+
message:
71+
'Member credit limit exceeded: 900 of 1,000 credits used. Ask an admin to raise your credit limit.',
72+
})
73+
74+
const error = await admitCustomBlockChildExecution(attribution).catch((e: Error) => e)
75+
76+
expect(error).toBeInstanceOf(CustomBlockAdmissionError)
77+
expect(error.message).not.toContain('Member credit limit')
78+
expect(error.message).not.toContain('900')
79+
expect(error.message).toBe(
80+
'This custom block is unavailable because a usage limit was reached. Ask an organization admin to review it.'
81+
)
82+
})
83+
84+
it("never forwards the owner's account-block message to a consumer", async () => {
85+
mockCheckAttributedUsageLimits.mockResolvedValue({
86+
isExceeded: true,
87+
scope: 'actor',
88+
message: 'Account frozen. Please contact support to resolve this issue.',
89+
})
90+
91+
const error = await admitCustomBlockChildExecution(attribution).catch((e: Error) => e)
92+
93+
expect(error.message).not.toContain('Account frozen')
94+
expect(error.message).not.toContain('support')
95+
})
96+
6597
it('takes no concurrency reservation', async () => {
6698
mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false })
6799

apps/sim/lib/workflows/custom-blocks/child-execution.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ import {
1111
import { BoundarySafeError } from '@/executor/errors/boundary'
1212

1313
/**
14-
* The source workspace's payer has no headroom for this custom-block child run.
14+
* The payer has no headroom for this custom-block child run.
1515
*
16-
* Boundary-safe: publishing and invocation are both gated on a single
17-
* organization, so the exhausted limit always belongs to the consumer's own org.
18-
* Surfacing it lets them act on it instead of seeing an opaque failure.
16+
* Boundary-safe only as far as the message allows: see
17+
* {@link admitCustomBlockChildExecution} for why actor-scoped denials are
18+
* collapsed to {@link GENERIC_USAGE_LIMIT_MESSAGE} before reaching here.
1919
*/
2020
export class CustomBlockAdmissionError extends BoundarySafeError {
2121
constructor(message: string) {
@@ -24,6 +24,13 @@ export class CustomBlockAdmissionError extends BoundarySafeError {
2424
}
2525
}
2626

27+
/**
28+
* Consumer-safe stand-in for a denial whose real message describes the SOURCE
29+
* workflow owner's personal billing state rather than the shared organization.
30+
*/
31+
const GENERIC_USAGE_LIMIT_MESSAGE =
32+
'This custom block is unavailable because a usage limit was reached. Ask an organization admin to review it.'
33+
2734
/**
2835
* Admits one custom-block child run against the SOURCE workspace's payer.
2936
*
@@ -39,9 +46,23 @@ export async function admitCustomBlockChildExecution(
3946
attribution: BillingAttributionSnapshot
4047
): Promise<void> {
4148
const usage = await checkAttributedUsageLimits(attribution)
42-
if (usage.isExceeded) {
43-
throw new CustomBlockAdmissionError(usage.message ?? 'Workspace usage limit exceeded')
44-
}
49+
if (!usage.isExceeded) return
50+
51+
// Only the payer-scoped denial describes the shared organization ("Organization
52+
// usage limit exceeded: $X pooled of $Y organization limit"), which both sides
53+
// genuinely share and which the consumer can act on.
54+
//
55+
// The other gates are evaluated against `actorUserId` — the SOURCE workflow's
56+
// owner — and their text is addressed to that person: their account-frozen /
57+
// payment-method state, or "Ask an organization admin to raise YOUR credit
58+
// limit" against their individual member cap. Forwarding those verbatim would
59+
// show one org member another's private billing state. Being in the same
60+
// organization makes the *payer* shared; it does not make personal quotas
61+
// shared. The `usage_limit` type still tells the consumer what kind of failure
62+
// this was.
63+
const message =
64+
usage.scope === 'payer' && usage.message ? usage.message : GENERIC_USAGE_LIMIT_MESSAGE
65+
throw new CustomBlockAdmissionError(message)
4566
}
4667

4768
/**

0 commit comments

Comments
 (0)