fix: hold a team seat to the free caps it was meant to have

Review catch by @Salazareo: `org_seat_free` reached `FREE_SUBSCRIPTION_IDS`
and so the `requireSubscription` gate, but two other surfaces decide on
plan and neither consults that set.

`bySubscription` maps name `user_free` and `temp_free`. A plan that
matches no key fell through to the top-level `limit` -- the paid cap --
so a seat outranked an ordinary free account: 240 event listings a minute
against their 120, and the same shape across the kv, notification,
subdomain and worker drivers. Both resolvers now fall back to the
`user_free` entry for anything in the free set, which covers every driver
at once and any free plan added later.

`subscriberOnly` compared against the two named ids, so a seat could
reach a paid-only model. It asks the set now.

A paid plan that names no cap of its own still takes the base, and an
unresolved plan still takes the base; there are tests for both so the
fallback cannot widen into "free by default".
This commit is contained in:
Juan Castro
2026-09-14 09:44:32 -04:00
parent 8d90418c9e
commit 15527eac4c
6 changed files with 125 additions and 14 deletions
@@ -27,6 +27,7 @@ import { describe, expect, it } from 'vitest';
import {
DEFAULT_FREE_SUBSCRIPTION,
DEFAULT_TEMP_SUBSCRIPTION,
ORG_SEAT_FREE_SUBSCRIPTION,
} from '../../services/metering/consts.js';
import {
EVENTS_BROADCAST_DELIVERY_LIMIT,
@@ -58,6 +59,23 @@ describe('the tiered subscription quotas', () => {
expect(tier.bySubscription[DEFAULT_TEMP_SUBSCRIPTION]).toBe(0);
});
it('holds a free plan nobody enumerated to the free cap, not the paid one', () => {
// A team seat resolves to `org_seat_free`, which no tier names. Falling
// through to `limit` would give it more than an ordinary free account.
for (const tier of tiers.map(([, t]) => t)) {
expect(limitFor(tier, ORG_SEAT_FREE_SUBSCRIPTION)).toBe(
tier.bySubscription[DEFAULT_FREE_SUBSCRIPTION],
);
}
});
it('still holds an unresolved or paid plan to the base', () => {
for (const tier of tiers.map(([, t]) => t)) {
expect(limitFor(tier, null)).toBe(tier.limit);
expect(limitFor(tier, 'some-paid-tier')).toBe(tier.limit);
}
});
it('keeps what one app may take below what the account may hold', () => {
for (const plan of [
null,
+10 -5
View File
@@ -21,6 +21,7 @@ import type { RouteRateLimit } from '../../core/http/types';
import {
DEFAULT_FREE_SUBSCRIPTION,
DEFAULT_TEMP_SUBSCRIPTION,
FREE_SUBSCRIPTION_IDS,
} from '../../services/metering/consts.js';
// -- Shared event limits ---------------------------------------------
@@ -61,14 +62,18 @@ const tiered = (paid: number, free: number, temp: number): TieredLimit => ({
},
});
/** The cap one plan sees. An unresolved plan is held to the base. */
/** The cap one plan sees; an unlisted free plan takes the free one, not `limit`. */
export const limitFor = (
tier: TieredLimit,
subscriptionId: string | null,
): number =>
(subscriptionId === null
? undefined
: tier.bySubscription[subscriptionId]) ?? tier.limit;
): number => {
if (subscriptionId === null) return tier.limit;
const own = tier.bySubscription[subscriptionId];
if (typeof own === 'number') return own;
return FREE_SUBSCRIPTION_IDS.has(subscriptionId)
? (tier.bySubscription[DEFAULT_FREE_SUBSCRIPTION] ?? tier.limit)
: tier.limit;
};
/**
* What a plan-tiered quota resolves to: what an account may hold, and what one
+14 -1
View File
@@ -21,6 +21,10 @@
import crypto from 'node:crypto';
import { withSpan } from '../../../util/span.js';
import { HttpError } from '../HttpError.js';
import {
DEFAULT_FREE_SUBSCRIPTION,
FREE_SUBSCRIPTION_IDS,
} from '../../../services/metering/consts.js';
/**
* Sliding-window rate limiter with swappable, **co-resident** backends.
@@ -808,6 +812,15 @@ export const CONCURRENT_SLOT_TTL_MS = ORPHAN_SAFETY_TTL_MS;
* actor, no metering, metering throws) falls through to the base — rate /
* concurrency limiting should never _amplify_ a request failure path.
*/
// An unlisted free plan would otherwise take `limit`, the paid cap.
function overrideFor(bySubscription, subscriptionId) {
const own = bySubscription[subscriptionId];
if (typeof own === 'number') return own;
return FREE_SUBSCRIPTION_IDS.has(subscriptionId)
? bySubscription[DEFAULT_FREE_SUBSCRIPTION]
: undefined;
}
async function resolveSubscriptionLimit(req, opts) {
const base = opts.limit;
if (!opts.bySubscription || !meteringService) return base;
@@ -815,7 +828,7 @@ async function resolveSubscriptionLimit(req, opts) {
if (!actor?.user?.uuid) return base;
try {
const sub = await meteringService.getActorSubscription(actor);
const override = opts.bySubscription[sub.id];
const override = overrideFor(opts.bySubscription, sub.id);
return typeof override === 'number' ? override : base;
} catch {
return base;
@@ -406,6 +406,51 @@ describe('rateLimitGate — bySubscription overrides', () => {
expect(await runGate(opts, paidReq)).toBeUndefined();
});
it('holds a free plan the spec never named to the free cap', async () => {
// A team seat resolves to `org_seat_free`; no driver enumerates it, and
// falling through to `limit` would outrank an ordinary free account.
configureRateLimit({
metering: {
getActorSubscription: async () => ({ id: 'org_seat_free' }),
},
});
const opts = {
limit: 100,
window: 60_000,
bySubscription: { user_free: 1 },
key: 'user',
scope: 'rl-sub-orgseat',
};
const req = () => ({
actor: { user: { id: 7, uuid: 'seat' } },
headers: {},
});
expect(await runGate(opts, req())).toBeUndefined();
expect(isHttpError(await runGate(opts, req()))).toBe(true);
});
it('still gives a paid plan the base when it names no cap of its own', async () => {
configureRateLimit({
metering: {
getActorSubscription: async () => ({ id: 'some-paid-tier' }),
},
});
const opts = {
limit: 2,
window: 60_000,
bySubscription: { user_free: 1 },
key: 'user',
scope: 'rl-sub-paid-unlisted',
};
const req = () => ({
actor: { user: { id: 8, uuid: 'paid2' } },
headers: {},
});
expect(await runGate(opts, req())).toBeUndefined();
expect(await runGate(opts, req())).toBeUndefined();
expect(isHttpError(await runGate(opts, req()))).toBe(true);
});
it('falls back to the base `limit` when metering throws', async () => {
configureRateLimit({
metering: {
@@ -738,6 +738,41 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
});
}
it('rejects subscriber-only models for a team seat on the free org plan', async () => {
// `org_seat_free` pays nothing, so it must not reach a paid model —
// the gate checks every free plan, not two named ones.
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
{
id: 'subonly-seat',
aliases: [],
costs_currency: 'usd-cents',
costs: { 'input-tokens': 100, 'output-tokens': 100 },
max_tokens: 8192,
subscriberOnly: true,
},
]);
const d = await makeDriver();
vi.spyOn(server.services.metering, 'getRemainingUsage').mockResolvedValue(
1_000_000,
);
vi.spyOn(
server.services.metering,
'getActorSubscription',
).mockResolvedValue({ id: 'org_seat_free' } as never);
await expect(
withTestActor(() =>
d.complete({
model: 'subonly-seat',
messages: [{ role: 'user', content: 'hi' }],
}),
),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'permission_denied',
});
});
it('rejects subscriber-only models for the default free subscription', async () => {
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
{
@@ -23,10 +23,7 @@ import { EventMap } from '../../clients/event/types.js';
import type { Actor } from '../../core/actor.js';
import { Context } from '../../core/context.js';
import { HttpError, isHttpError } from '../../core/http/HttpError.js';
import {
DEFAULT_FREE_SUBSCRIPTION,
DEFAULT_TEMP_SUBSCRIPTION,
} from '../../services/metering/consts.js';
import { FREE_SUBSCRIPTION_IDS } from '../../services/metering/consts.js';
import type { CreditHold } from '../../services/metering/types.js';
import { NO_CREDIT_HOLD } from '../../services/metering/types.js';
import type { DriverStreamResult } from '../meta.js';
@@ -920,10 +917,8 @@ export class ChatCompletionDriver extends PuterDriver {
if (model.subscriberOnly) {
const subscription = await metering.getActorSubscription(actor);
const isDefaultPolicy =
subscription.id === DEFAULT_FREE_SUBSCRIPTION ||
subscription.id === DEFAULT_TEMP_SUBSCRIPTION;
if (isDefaultPolicy) {
// Every free plan, not two named ones.
if (FREE_SUBSCRIPTION_IDS.has(subscription.id)) {
throw new HttpError(
403,
`The model ${model.id} is only available to subscribers. Please subscribe to access this model.`,