mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-17 09:09:43 +00:00
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:
1 parent
d8eb8d667f
commit
17034e1b0d
8 files changed
+165
-6
No files matched your search
@@ -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
@@ -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,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user