mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-22 21:26:04 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
Reference in New Issue
Block a user