feat: let an extension waive a plan gate for one route or driver method

A plan gate about to refuse a caller now emits
`subscription.gate.<surface>` through `emitAndWait` first:
`route.<method>.<path>` for a route, `driver.<iface>.<method>` for a driver
method, `share.anyone` for link shares. The payload carries the actor, the
request and the requirement, and a listener that sets `allow` waives the plan
for that call. Callers the plan already covers never reach the hook, and a
listener that throws leaves the refusal in place.
This commit is contained in:
Daniel Salazar committed 2026-10-04 22:07:21 -07:00
1 parent d8eb8d667f
commit 17034e1b0d
8 files changed
+165 -6

No files matched your search

+18
View File
@@ -767,6 +767,12 @@ export type EventMap = {
// `ai.cost.factor.<driverName>.<provider>:<model>`. Emitted once per model
// per batch; the last listener to set `factor` wins.
[K in `ai.cost.factor.${string}`]: AiCostFactorEvent;
} & {
// A plan gate is about to refuse a caller. Keyed per surface the way the
// lifecycles above are: `subscription.gate.route.<method>.<path>`,
// `subscription.gate.driver.<iface>.<method>`. Emitted via `emitAndWait`;
// a listener that sets `allow` waives the plan requirement for that call.
[K in `subscription.gate.${string}`]: SubscriptionGateEvent;
} & {
[K in `pubsub.login.${string}`]: { authtoken: string };
} & {
@@ -787,6 +793,18 @@ export type EventMap = {
'outer.pubsub.metering.credits-changed': { userUuid: string };
} & IExtensionEventMap;
/** Payload for `subscription.gate.<surface>` events. */
export type SubscriptionGateEvent = {
actor: Actor;
/** The key after `subscription.gate.`, e.g. `driver.puter-kvstore.get`. */
surface: string;
/** What the gate asked for: any paid plan (`true`) or these policy ids. */
requirement: boolean | readonly string[];
/** The request being gated, when there is one. */
req?: ExpressRequest;
allow: boolean;
};
/** Payload for `ai.cost.factor.<driver>.<model>` events. */
export type AiCostFactorEvent = {
/** Driver doing the pricing, e.g. `ai-chat`. */
@@ -21,6 +21,7 @@
import { EventEmitter } from 'node:events';
import type { Request, RequestHandler, Response } from 'express';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { EventClient } from '../../clients/event/EventClient.js';
import { runWithContext } from '../../core/context.js';
import { isHttpError } from '../../core/http/HttpError.js';
import { configureRateLimit } from '../../core/http/middleware/rateLimit.js';
@@ -97,8 +98,8 @@ const syntheticDriver = {
premium: async () => 'premium-ok',
};
const buildController = (plan: string | null) => {
const clients = { alarm: { create: () => {} } };
const buildController = (plan: string | null, events?: EventClient) => {
const clients = { alarm: { create: () => {} }, ...(events ? { event: events } : {}) };
const services = plan
? {
metering: {
@@ -115,8 +116,13 @@ const buildController = (plan: string | null) => {
);
};
const call = (plan: string | null, method: string, tag: string) => {
const handler = captureCallHandler(buildController(plan));
const call = (
plan: string | null,
method: string,
tag: string,
events?: EventClient,
) => {
const handler = captureCallHandler(buildController(plan, events));
const res = new StubRes();
return runWithContext({}, () =>
handler(makeReq(method, tag), res as unknown as Response, () => {}),
@@ -157,6 +163,17 @@ describe('DriverController — per-method subscription requirement', () => {
expect(res.body).toMatchObject({ success: true, result: 'premium-ok' });
});
it('lets a subscription.gate listener waive one method for a free account', async () => {
const events = new EventClient({} as any);
events.on('subscription.gate.driver.test-iface.paid', (_key, event) => {
(event as { allow: boolean }).allow = true;
});
const res = await call('user_free', 'paid', 'waived', events);
expect(res.body).toMatchObject({ success: true, result: 'paid-ok' });
await expect402(call('user_free', 'premium', 'not-waived', events));
});
it('enforces nothing on a deployment with no metering service', async () => {
const res = await call(null, 'paid', 'no-metering');
expect(res.body).toMatchObject({ success: true, result: 'paid-ok' });
@@ -390,6 +390,11 @@ export class DriverController extends PuterController {
req.actor,
subscriptionRequirement,
this.config,
{
events: this.clients.event,
surface: `driver.${ifaceName}.${method}`,
req,
},
);
}
@@ -19,6 +19,7 @@
import type { Request, Response } from 'express';
import { describe, expect, it, vi } from 'vitest';
import { EventClient } from '../../../clients/event/EventClient';
import type { IConfig } from '../../../types';
import { isHttpError } from '../HttpError';
import { requireSubscriptionGate } from './subscription';
@@ -79,3 +80,65 @@ describe('requireSubscriptionGate', () => {
expect(got).toBeInstanceOf(Error);
});
});
describe('subscription.gate hook', () => {
const surface = 'route.post.puterai.anthropic.v1.messages';
it('lets a listener on the surface waive the plan for that call', async () => {
const events = new EventClient({} as IConfig);
const seen: unknown[] = [];
events.on(`subscription.gate.${surface}`, (_key, event) => {
seen.push(event);
(event as { allow: boolean }).allow = true;
});
const got = await runGate(
requireSubscriptionGate(metering('user_free'), config, true, {
events,
surface,
}),
'user_free',
);
expect(got).toBeUndefined();
expect(seen).toHaveLength(1);
expect(seen[0]).toMatchObject({
surface,
requirement: true,
actor: { user: { uuid: 'u-1' } },
});
});
it('still refuses when nothing answers, or a listener throws', async () => {
const events = new EventClient({} as IConfig);
events.on('subscription.gate.*', () => {
throw new Error('listener bug');
});
const got = await runGate(
requireSubscriptionGate(metering('user_free'), config, true, {
events,
surface,
}),
'user_free',
);
expect(got).toMatchObject({ statusCode: 402 });
});
it('is not asked about a caller the plan already covers', async () => {
const events = new EventClient({} as IConfig);
const listener = vi.fn();
events.on('subscription.gate.*', listener);
await runGate(
requireSubscriptionGate(metering('business'), config, true, {
events,
surface,
}),
'business',
);
expect(listener).not.toHaveBeenCalled();
});
});
@@ -20,6 +20,7 @@
import type { RequestHandler } from 'express';
import {
assertActorHasSubscription,
type SubscriptionGateHook,
type SubscriptionMetering,
type SubscriptionRequirement,
} from '../../../services/metering/enforcement.js';
@@ -40,6 +41,7 @@ export const requireSubscriptionGate = (
metering: SubscriptionMetering | undefined,
config: IConfig,
requirement: SubscriptionRequirement,
hook?: Omit<SubscriptionGateHook, 'req'>,
): RequestHandler => {
return (req, _res, next) => {
assertActorHasSubscription(
@@ -47,6 +49,7 @@ export const requireSubscriptionGate = (
req.actor,
requirement,
config,
hook && { ...hook, req },
).then(
() => next(),
(err) => next(err),
+16 -1
View File
@@ -85,7 +85,10 @@ import { createEgressMeteringMiddleware } from './core/http/middleware/egressMet
import { createLocalWorkerProxyMiddleware } from './core/http/middleware/localWorkerProxy';
import { createPuterSiteMiddleware } from './core/http/middleware/puterSite';
import { PuterRouter } from './core/http/PuterRouter';
import { createRouteLifecycleMiddleware } from './core/http/routeLifecycle';
import {
createRouteLifecycleMiddleware,
routeEventKeyBase,
} from './core/http/routeLifecycle';
import { PREFIX_METADATA_KEY, type RouteDescriptor } from './core/http/types';
import type { AuthService } from './services/auth/AuthService';
import { puterDrivers } from './drivers';
@@ -1147,6 +1150,18 @@ export class PuterServer {
this.services.metering,
this.#config,
subscriptionRequirement!,
{
events: this.clients.event,
surface: routeEventKeyBase(
route.method,
route.path !== undefined
? PuterServer.#joinPath(
routerPrefix,
route.path,
)
: undefined,
),
},
),
);
}
+38 -1
View File
@@ -17,6 +17,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Request } from 'express';
import type { EventClient } from '../../clients/event/EventClient';
import type { Actor } from '../../core/actor';
import { isSystemActor } from '../../core/actor';
import { HttpError } from '../../core/http/HttpError.js';
@@ -215,6 +217,38 @@ export const actorOnPaidPlan = async (
return subscriptionSatisfies(subscription.id, true);
};
/**
* Where a plan gate sits, so a `subscription.gate.<surface>` listener can waive
* it for a call it recognizes. `surface` is the key segment after
* `subscription.gate.`, e.g. `route.post.puterai.openai.v1.chat.completions`.
*/
export interface SubscriptionGateHook {
events: Pick<EventClient, 'emitAndWait'> | undefined;
surface: string;
req?: Request;
}
const waivedByHook = async (
hook: SubscriptionGateHook | undefined,
actor: Actor,
requirement: SubscriptionRequirement,
): Promise<boolean> => {
if (!hook?.events) return false;
const event = {
actor,
surface: hook.surface,
requirement,
req: hook.req,
allow: false,
};
await hook.events.emitAndWait(
`subscription.gate.${hook.surface}`,
event,
{},
);
return event.allow === true;
};
/**
* Reject a caller whose plan doesn't cover the surface they're calling.
*
@@ -224,13 +258,15 @@ export const actorOnPaidPlan = async (
* metering wired — there are no plans to be on.
*
* The answer comes from the metering service's per-actor subscription cache, so
* this normally costs a map lookup.
* this normally costs a map lookup. A caller the plan doesn't cover is offered
* to `hook` listeners before being refused.
*/
export const assertActorHasSubscription = async (
metering: SubscriptionMetering,
actor: Actor | undefined,
requirement: SubscriptionRequirement,
config: EnforcementConfig,
hook?: SubscriptionGateHook,
): Promise<void> => {
if (subscriptionCheckWaived(metering, actor, requirement, config)) return;
@@ -242,6 +278,7 @@ export const assertActorHasSubscription = async (
const subscription = await metering!.getActorSubscription(actor);
if (subscriptionSatisfies(subscription.id, requirement)) return;
if (await waivedByHook(hook, actor, requirement)) return;
throw new HttpError(402, 'A subscription is required for this action', {
legacyCode: 'subscription_required',
@@ -2811,6 +2811,7 @@ export class ShareService extends PuterService {
actor,
true,
this.config,
{ events: this.clients.event, surface: 'share.anyone' },
);
// Moving an existing link to another mode is not new reach.