mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-11 06:11:56 +00:00
refactor(drivers): one per-method policy validator and resolver; drop @Driver
Four policy blocks (rateLimit, concurrent, requireSubscription,
requireReputation) share the { default, methods } shape but each had its
own validator and resolver. They become validatePerMethod(value, label,
policy) and resolvePerMethod(cfg, method); error messages are unchanged.
The @Driver class decorator and the prototype keys it wrote had no users
outside its own tests; drivers declare their fields, and resolveDriverMeta
reads only those. DriverController takes the driver name from the meta it
already resolved instead of probing the prototype.
resolveDriverMethodRateLimit/Concurrent stay as deprecated aliases for the
email extension.
This commit is contained in:
1 parent
0d4dc29d0a
commit
d0100a5c66
7 files changed
+249
-748
No files matched your search
@@ -41,10 +41,7 @@ import {
|
||||
isDriverStreamResult,
|
||||
resolveCallableMethods,
|
||||
resolveDriverMeta,
|
||||
resolveDriverMethodConcurrent,
|
||||
resolveDriverMethodRateLimit,
|
||||
resolveDriverMethodRequireReputation,
|
||||
resolveDriverMethodRequireSubscription,
|
||||
resolvePerMethod,
|
||||
} from '../../drivers/meta.js';
|
||||
import { assertActorHasSubscription } from '../../services/metering/enforcement.js';
|
||||
import type { PermissionService } from '../../services/permission/PermissionService.js';
|
||||
@@ -301,16 +298,10 @@ export class DriverController extends PuterController {
|
||||
}
|
||||
const fn = driver[method];
|
||||
|
||||
// Resolve the concrete driver name for permission keys, falling
|
||||
// back through prototype metadata → instance field → requested name.
|
||||
const resolvedDriverName =
|
||||
(driver as Record<string, unknown>).driverName ??
|
||||
(Object.getPrototypeOf(driver) as Record<string, unknown>)
|
||||
.__driverName ??
|
||||
requestedDriver ??
|
||||
'unknown';
|
||||
|
||||
const driverMeta = this.#meta.get(driver);
|
||||
// The concrete driver name, not an alias, keys the permission check.
|
||||
const resolvedDriverName =
|
||||
driverMeta?.driverName ?? requestedDriver ?? 'unknown';
|
||||
|
||||
// Drivers flagged `noUserSession` refuse the bare
|
||||
// account-session ("root") token: callers must present an app or
|
||||
@@ -355,13 +346,13 @@ export class DriverController extends PuterController {
|
||||
}
|
||||
|
||||
// Methods that ask for a trusted-enough account. Declared per-driver
|
||||
// (`@Driver({ requireReputation })`) for the same reason the
|
||||
// (`requireReputation`) for the same reason the
|
||||
// subscription block is. Checked ahead of the plan and rate-limit
|
||||
// gates, matching the route chain: whether this account should be
|
||||
// reaching the method at all is settled before what it pays for or how
|
||||
// often it may ask. Inert unless the running config gives the named
|
||||
// tier a score.
|
||||
const reputationRequirement = resolveDriverMethodRequireReputation(
|
||||
const reputationRequirement = resolvePerMethod(
|
||||
driverMeta?.requireReputation,
|
||||
method,
|
||||
);
|
||||
@@ -373,13 +364,13 @@ export class DriverController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
// Subscriber-only methods. Declared per-driver
|
||||
// (`@Driver({ requireSubscription })`) because `/drivers/call` is a
|
||||
// single route and a route option would apply to every driver at once.
|
||||
// Subscriber-only methods. Declared per-driver (`requireSubscription`)
|
||||
// because `/drivers/call` is a single route and a route option would
|
||||
// apply to every driver at once.
|
||||
// Checked before the rate limit — the same order the route chain uses
|
||||
// — so a caller whose plan never included the method is told that
|
||||
// rather than spending a bucket on it.
|
||||
const subscriptionRequirement = resolveDriverMethodRequireSubscription(
|
||||
const subscriptionRequirement = resolvePerMethod(
|
||||
driverMeta?.requireSubscription,
|
||||
method,
|
||||
);
|
||||
@@ -398,16 +389,12 @@ export class DriverController extends PuterController {
|
||||
}
|
||||
|
||||
// Per-method rate-limit and concurrent specs both live on the
|
||||
// driver's resolved meta (set by `@Driver({ rateLimit, concurrent })`
|
||||
// or imperative fields). Rate-limit is single-shot; concurrent
|
||||
// driver's resolved meta. Rate-limit is single-shot; concurrent
|
||||
// acquires a slot that must be released when the response is done
|
||||
// — we hook `res.finish` / `res.close` for that so streamed
|
||||
// responses hold their slot until the stream drains, and aborted
|
||||
// requests still give the slot back.
|
||||
const rateLimitSpec = resolveDriverMethodRateLimit(
|
||||
driverMeta?.rateLimit,
|
||||
method,
|
||||
);
|
||||
const rateLimitSpec = resolvePerMethod(driverMeta?.rateLimit, method);
|
||||
if (
|
||||
!(await checkDriverRateLimit(req, ifaceName, method, rateLimitSpec))
|
||||
) {
|
||||
@@ -418,10 +405,7 @@ export class DriverController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
const concurrentSpec = resolveDriverMethodConcurrent(
|
||||
driverMeta?.concurrent,
|
||||
method,
|
||||
);
|
||||
const concurrentSpec = resolvePerMethod(driverMeta?.concurrent, method);
|
||||
// Only acquire (and attach release listeners) when the driver
|
||||
// actually declared a concurrency cap. Skipping in the unbounded
|
||||
// case keeps the hot path free of needless event-listener churn
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
DRIVER_CONCURRENT_KEY,
|
||||
DRIVER_DEFAULT_KEY,
|
||||
DRIVER_INTERFACE_KEY,
|
||||
DRIVER_NAME_KEY,
|
||||
DRIVER_NO_USER_SESSION_KEY,
|
||||
DRIVER_RATE_LIMIT_KEY,
|
||||
DRIVER_REQUIRE_REPUTATION_KEY,
|
||||
DRIVER_REQUIRE_SUBSCRIPTION_KEY,
|
||||
validateDriverConcurrent,
|
||||
validateDriverRateLimit,
|
||||
validateDriverRequireReputation,
|
||||
validateDriverRequireSubscription,
|
||||
type DriverConcurrentConfig,
|
||||
type DriverRateLimitConfig,
|
||||
type DriverRequireReputationConfig,
|
||||
type DriverRequireSubscriptionConfig,
|
||||
} from './meta';
|
||||
|
||||
/**
|
||||
* Options for the `@Driver` class decorator. Each policy takes `{ default?,
|
||||
* methods? }`; a method covered by neither is ungated (rate limits fall through
|
||||
* to the global driver default). See `DriverMeta` for semantics.
|
||||
*/
|
||||
export interface DriverOptions {
|
||||
/** Unique within the interface. Defaults to the class name. */
|
||||
name?: string;
|
||||
/** The default driver for its interface. */
|
||||
default?: boolean;
|
||||
rateLimit?: DriverRateLimitConfig;
|
||||
concurrent?: DriverConcurrentConfig;
|
||||
/** Reject bare account-session tokens on `/drivers/call`. */
|
||||
noUserSession?: boolean;
|
||||
/**
|
||||
* `true` accepts any non-free plan; an array of `SubscriptionPolicy.id`s
|
||||
* only those.
|
||||
*/
|
||||
requireSubscription?: DriverRequireSubscriptionConfig;
|
||||
/** Reputation tier names; thresholds come from `reputationGate.tiers`. */
|
||||
requireReputation?: DriverRequireReputationConfig;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyCtor = new (...args: any[]) => any;
|
||||
|
||||
/**
|
||||
* Class decorator that marks a driver implementation and records its interface
|
||||
*
|
||||
* - Name on the prototype.
|
||||
*
|
||||
* Equivalent imperative approach (no decorator needed):
|
||||
*
|
||||
* ```ts
|
||||
* class MyDriver extends PuterDriver {
|
||||
* readonly driverInterface = 'puter-chat-completion';
|
||||
* readonly driverName = 'my-impl';
|
||||
* readonly isDefault = true;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* ```ts
|
||||
* @Driver('puter-chat-completion', { name: 'openai-completion', default: true })
|
||||
* class OpenAIChatDriver extends PuterDriver {
|
||||
* async complete(args) { ... }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function Driver(interfaceName: string, opts: DriverOptions = {}) {
|
||||
// Validate eagerly at decoration time so a malformed rateLimit /
|
||||
// concurrent block surfaces during module load — not when the first
|
||||
// request hits the route and the controller resolves driver meta.
|
||||
const label = `@Driver('${interfaceName}'${opts.name ? `, name='${opts.name}'` : ''})`;
|
||||
const rateLimit =
|
||||
opts.rateLimit !== undefined
|
||||
? validateDriverRateLimit(opts.rateLimit, label)
|
||||
: undefined;
|
||||
const concurrent =
|
||||
opts.concurrent !== undefined
|
||||
? validateDriverConcurrent(opts.concurrent, label)
|
||||
: undefined;
|
||||
const requireSubscription =
|
||||
opts.requireSubscription !== undefined
|
||||
? validateDriverRequireSubscription(opts.requireSubscription, label)
|
||||
: undefined;
|
||||
const requireReputation =
|
||||
opts.requireReputation !== undefined
|
||||
? validateDriverRequireReputation(opts.requireReputation, label)
|
||||
: undefined;
|
||||
|
||||
return <T extends AnyCtor>(
|
||||
value: T,
|
||||
_context: ClassDecoratorContext<T>,
|
||||
): void => {
|
||||
const proto = value.prototype as Record<string, unknown>;
|
||||
proto[DRIVER_INTERFACE_KEY] = interfaceName;
|
||||
proto[DRIVER_NAME_KEY] = opts.name ?? value.name;
|
||||
proto[DRIVER_DEFAULT_KEY] = opts.default ?? false;
|
||||
if (rateLimit) proto[DRIVER_RATE_LIMIT_KEY] = rateLimit;
|
||||
if (concurrent) proto[DRIVER_CONCURRENT_KEY] = concurrent;
|
||||
if (requireSubscription)
|
||||
proto[DRIVER_REQUIRE_SUBSCRIPTION_KEY] = requireSubscription;
|
||||
if (requireReputation)
|
||||
proto[DRIVER_REQUIRE_REPUTATION_KEY] = requireReputation;
|
||||
if (opts.noUserSession) proto[DRIVER_NO_USER_SESSION_KEY] = true;
|
||||
};
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import { SubdomainDriver } from './subdomain/SubdomainDriver';
|
||||
import type { IPuterDriverRegistry } from './types';
|
||||
import { WorkerDriver } from './workers/WorkerDriver';
|
||||
|
||||
export { Driver } from './decorators';
|
||||
export { resolveDriverMeta } from './meta';
|
||||
|
||||
export const puterDrivers = {
|
||||
|
||||
+119
-213
@@ -18,29 +18,24 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Driver } from './decorators.js';
|
||||
import {
|
||||
resolveDriverMeta,
|
||||
resolveDriverMethodConcurrent,
|
||||
resolveDriverMethodRateLimit,
|
||||
resolveDriverMethodRequireReputation,
|
||||
resolveDriverMethodRequireSubscription,
|
||||
validateDriverConcurrent,
|
||||
validateDriverRateLimit,
|
||||
validateDriverRequireReputation,
|
||||
validateDriverRequireSubscription,
|
||||
resolvePerMethod,
|
||||
validatePerMethod,
|
||||
type DriverConcurrentConfig,
|
||||
type DriverRateLimitConfig,
|
||||
type DriverRequireReputationConfig,
|
||||
type DriverRequireSubscriptionConfig,
|
||||
} from './meta.js';
|
||||
|
||||
// ── validateDriverRateLimit ─────────────────────────────────────────
|
||||
// ── validatePerMethod: rateLimit ─────────────────────────────────────────
|
||||
|
||||
describe('validateDriverRateLimit', () => {
|
||||
describe('validatePerMethod: rateLimit', () => {
|
||||
it('returns an empty config for null/undefined input', () => {
|
||||
expect(validateDriverRateLimit(undefined, 't')).toEqual({});
|
||||
expect(validateDriverRateLimit(null, 't')).toEqual({});
|
||||
expect(validatePerMethod(undefined, 't', 'rateLimit')).toEqual({});
|
||||
expect(validatePerMethod(null, 't', 'rateLimit')).toEqual({});
|
||||
});
|
||||
|
||||
it('accepts a well-formed default + methods block', () => {
|
||||
@@ -51,39 +46,45 @@ describe('validateDriverRateLimit', () => {
|
||||
set: { limit: 100, window: 60_000, backend: 'redis' },
|
||||
},
|
||||
};
|
||||
expect(validateDriverRateLimit(cfg, 't')).toBe(cfg);
|
||||
expect(validatePerMethod(cfg, 't', 'rateLimit')).toBe(cfg);
|
||||
});
|
||||
|
||||
it('rejects a non-object config', () => {
|
||||
expect(() => validateDriverRateLimit(42, 't')).toThrow(
|
||||
expect(() => validatePerMethod(42, 't', 'rateLimit')).toThrow(
|
||||
/rateLimit must be an object/,
|
||||
);
|
||||
expect(() => validateDriverRateLimit([], 't')).toThrow(
|
||||
expect(() => validatePerMethod([], 't', 'rateLimit')).toThrow(
|
||||
/rateLimit must be an object/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-positive / non-numeric limit and window', () => {
|
||||
expect(() =>
|
||||
validateDriverRateLimit({ default: { limit: 0, window: 1 } }, 't'),
|
||||
validatePerMethod(
|
||||
{ default: { limit: 0, window: 1 } },
|
||||
't',
|
||||
'rateLimit',
|
||||
),
|
||||
).toThrow(/limit: expected a positive number/);
|
||||
expect(() =>
|
||||
validateDriverRateLimit(
|
||||
validatePerMethod(
|
||||
{ default: { limit: 1, window: -10 } },
|
||||
't',
|
||||
'rateLimit',
|
||||
),
|
||||
).toThrow(/window: expected a positive number/);
|
||||
expect(() =>
|
||||
validateDriverRateLimit(
|
||||
validatePerMethod(
|
||||
{ default: { limit: 'x', window: 60_000 } },
|
||||
't',
|
||||
'rateLimit',
|
||||
),
|
||||
).toThrow(/limit: expected a positive number/);
|
||||
});
|
||||
|
||||
it('rejects unknown backend names', () => {
|
||||
expect(() =>
|
||||
validateDriverRateLimit(
|
||||
validatePerMethod(
|
||||
{
|
||||
default: {
|
||||
limit: 1,
|
||||
@@ -92,13 +93,14 @@ describe('validateDriverRateLimit', () => {
|
||||
},
|
||||
},
|
||||
't',
|
||||
'rateLimit',
|
||||
),
|
||||
).toThrow(/backend: expected one of/);
|
||||
});
|
||||
|
||||
it('walks the methods map and labels the failing entry', () => {
|
||||
expect(() =>
|
||||
validateDriverRateLimit(
|
||||
validatePerMethod(
|
||||
{
|
||||
methods: {
|
||||
goodOne: { limit: 5, window: 60_000 },
|
||||
@@ -106,13 +108,14 @@ describe('validateDriverRateLimit', () => {
|
||||
},
|
||||
},
|
||||
'drv',
|
||||
'rateLimit',
|
||||
),
|
||||
).toThrow(/drv\.rateLimit\.methods\.badOne\.window/);
|
||||
});
|
||||
|
||||
it('rejects a non-object methods bag', () => {
|
||||
expect(() =>
|
||||
validateDriverRateLimit({ methods: [] as unknown }, 't'),
|
||||
validatePerMethod({ methods: [] as unknown }, 't', 'rateLimit'),
|
||||
).toThrow(/methods must be an object/);
|
||||
});
|
||||
|
||||
@@ -126,13 +129,13 @@ describe('validateDriverRateLimit', () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(validateDriverRateLimit(cfg, 't')).toBe(cfg);
|
||||
expect(validatePerMethod(cfg, 't', 'rateLimit')).toBe(cfg);
|
||||
});
|
||||
|
||||
it('rejects malformed bySubscription entries on a rate-limit spec', () => {
|
||||
// Symmetry with the concurrent validator — bad numbers fail loud.
|
||||
expect(() =>
|
||||
validateDriverRateLimit(
|
||||
validatePerMethod(
|
||||
{
|
||||
default: {
|
||||
limit: 1,
|
||||
@@ -141,14 +144,15 @@ describe('validateDriverRateLimit', () => {
|
||||
},
|
||||
},
|
||||
'drv',
|
||||
'rateLimit',
|
||||
),
|
||||
).toThrow(/drv\.rateLimit\.default\.bySubscription\.user_free/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveDriverMethodRateLimit ────────────────────────────────────
|
||||
// ── resolvePerMethod: rateLimit ────────────────────────────────────
|
||||
|
||||
describe('resolveDriverMethodRateLimit', () => {
|
||||
describe('resolvePerMethod: rateLimit', () => {
|
||||
const cfg: DriverRateLimitConfig = {
|
||||
default: { limit: 100, window: 60_000 },
|
||||
methods: {
|
||||
@@ -157,7 +161,7 @@ describe('resolveDriverMethodRateLimit', () => {
|
||||
};
|
||||
|
||||
it('returns the per-method spec when one is declared', () => {
|
||||
expect(resolveDriverMethodRateLimit(cfg, 'get')).toEqual({
|
||||
expect(resolvePerMethod(cfg, 'get')).toEqual({
|
||||
limit: 1000,
|
||||
window: 60_000,
|
||||
backend: 'memory',
|
||||
@@ -165,19 +169,19 @@ describe('resolveDriverMethodRateLimit', () => {
|
||||
});
|
||||
|
||||
it('falls back to the default spec when no method override exists', () => {
|
||||
expect(resolveDriverMethodRateLimit(cfg, 'set')).toEqual({
|
||||
expect(resolvePerMethod(cfg, 'set')).toEqual({
|
||||
limit: 100,
|
||||
window: 60_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when the driver declared no rate-limit at all', () => {
|
||||
expect(resolveDriverMethodRateLimit(undefined, 'get')).toBeUndefined();
|
||||
expect(resolvePerMethod(undefined, 'get')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when neither default nor a matching method is declared', () => {
|
||||
expect(
|
||||
resolveDriverMethodRateLimit(
|
||||
resolvePerMethod(
|
||||
{ methods: { other: { limit: 1, window: 1 } } },
|
||||
'get',
|
||||
),
|
||||
@@ -185,53 +189,10 @@ describe('resolveDriverMethodRateLimit', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── @Driver decorator: rateLimit propagation ────────────────────────
|
||||
// ── resolveDriverMeta: rateLimit ────────────────────────────────────
|
||||
|
||||
describe('@Driver — rateLimit option', () => {
|
||||
it('stamps a validated rateLimit block onto the prototype, surfacing via resolveDriverMeta', () => {
|
||||
@Driver('test-iface', {
|
||||
name: 'test-impl',
|
||||
rateLimit: {
|
||||
default: { limit: 50, window: 60_000 },
|
||||
methods: {
|
||||
chat: { limit: 10, window: 60_000, backend: 'redis' },
|
||||
},
|
||||
},
|
||||
})
|
||||
class FakeDriver {}
|
||||
|
||||
const inst = new FakeDriver();
|
||||
const meta = resolveDriverMeta(
|
||||
inst as unknown as Record<string, unknown> & {
|
||||
onServerStart?: () => void;
|
||||
onServerPrepareShutdown?: () => void;
|
||||
onServerShutdown?: () => void;
|
||||
},
|
||||
);
|
||||
expect(meta).not.toBeNull();
|
||||
expect(meta?.rateLimit).toEqual({
|
||||
default: { limit: 50, window: 60_000 },
|
||||
methods: {
|
||||
chat: { limit: 10, window: 60_000, backend: 'redis' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('throws at decoration time on a malformed rateLimit block', () => {
|
||||
// The whole point of eager validation: bad config takes the
|
||||
// module down at boot, not at the first request.
|
||||
expect(() => {
|
||||
@Driver('test-iface', {
|
||||
name: 'broken',
|
||||
rateLimit: { default: { limit: -1, window: 1_000 } },
|
||||
})
|
||||
class BrokenDriver {}
|
||||
void BrokenDriver;
|
||||
}).toThrow(/limit: expected a positive number/);
|
||||
});
|
||||
|
||||
it('falls back to imperative `rateLimit` field when no decorator metadata is set', () => {
|
||||
// Imperative drivers (no decorator) declare the field directly.
|
||||
describe('resolveDriverMeta — rateLimit', () => {
|
||||
it('reads the `rateLimit` field', () => {
|
||||
class Imperative {
|
||||
readonly driverInterface = 'imp-iface';
|
||||
readonly driverName = 'imp';
|
||||
@@ -253,7 +214,7 @@ describe('@Driver — rateLimit option', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('validates the imperative `rateLimit` field on first read (loud failure)', () => {
|
||||
it('validates the `rateLimit` field on first read (loud failure)', () => {
|
||||
class BadImperative {
|
||||
readonly driverInterface = 'imp-iface';
|
||||
readonly driverName = 'imp-bad';
|
||||
@@ -274,12 +235,12 @@ describe('@Driver — rateLimit option', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── validateDriverConcurrent ────────────────────────────────────────
|
||||
// ── validatePerMethod: concurrent ────────────────────────────────────────
|
||||
|
||||
describe('validateDriverConcurrent', () => {
|
||||
describe('validatePerMethod: concurrent', () => {
|
||||
it('returns an empty config for null/undefined input', () => {
|
||||
expect(validateDriverConcurrent(undefined, 't')).toEqual({});
|
||||
expect(validateDriverConcurrent(null, 't')).toEqual({});
|
||||
expect(validatePerMethod(undefined, 't', 'concurrent')).toEqual({});
|
||||
expect(validatePerMethod(null, 't', 'concurrent')).toEqual({});
|
||||
});
|
||||
|
||||
it('accepts a well-formed default + methods block with bySubscription', () => {
|
||||
@@ -293,30 +254,31 @@ describe('validateDriverConcurrent', () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(validateDriverConcurrent(cfg, 't')).toBe(cfg);
|
||||
expect(validatePerMethod(cfg, 't', 'concurrent')).toBe(cfg);
|
||||
});
|
||||
|
||||
it('rejects non-positive / non-numeric limit', () => {
|
||||
expect(() =>
|
||||
validateDriverConcurrent({ default: { limit: 0 } }, 't'),
|
||||
validatePerMethod({ default: { limit: 0 } }, 't', 'concurrent'),
|
||||
).toThrow(/limit: expected a positive number/);
|
||||
expect(() =>
|
||||
validateDriverConcurrent({ default: { limit: 'x' } }, 't'),
|
||||
validatePerMethod({ default: { limit: 'x' } }, 't', 'concurrent'),
|
||||
).toThrow(/limit: expected a positive number/);
|
||||
});
|
||||
|
||||
it('rejects unknown backend names', () => {
|
||||
expect(() =>
|
||||
validateDriverConcurrent(
|
||||
validatePerMethod(
|
||||
{ default: { limit: 1, backend: 'sqlite' } },
|
||||
't',
|
||||
'concurrent',
|
||||
),
|
||||
).toThrow(/backend: expected one of/);
|
||||
});
|
||||
|
||||
it('rejects malformed bySubscription entries with a labelled path', () => {
|
||||
expect(() =>
|
||||
validateDriverConcurrent(
|
||||
validatePerMethod(
|
||||
{
|
||||
default: {
|
||||
limit: 5,
|
||||
@@ -324,13 +286,14 @@ describe('validateDriverConcurrent', () => {
|
||||
},
|
||||
},
|
||||
'drv',
|
||||
'concurrent',
|
||||
),
|
||||
).toThrow(/drv\.concurrent\.default\.bySubscription\.user_free/);
|
||||
});
|
||||
|
||||
it('walks the methods map and labels the failing entry', () => {
|
||||
expect(() =>
|
||||
validateDriverConcurrent(
|
||||
validatePerMethod(
|
||||
{
|
||||
methods: {
|
||||
goodOne: { limit: 5 },
|
||||
@@ -338,14 +301,15 @@ describe('validateDriverConcurrent', () => {
|
||||
},
|
||||
},
|
||||
'drv',
|
||||
'concurrent',
|
||||
),
|
||||
).toThrow(/drv\.concurrent\.methods\.badOne\.backend/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveDriverMethodConcurrent ───────────────────────────────────
|
||||
// ── resolvePerMethod: concurrent ───────────────────────────────────
|
||||
|
||||
describe('resolveDriverMethodConcurrent', () => {
|
||||
describe('resolvePerMethod: concurrent', () => {
|
||||
const cfg: DriverConcurrentConfig = {
|
||||
default: { limit: 3 },
|
||||
methods: {
|
||||
@@ -354,77 +318,28 @@ describe('resolveDriverMethodConcurrent', () => {
|
||||
};
|
||||
|
||||
it('returns the per-method spec when one is declared', () => {
|
||||
expect(resolveDriverMethodConcurrent(cfg, 'heavy')).toEqual({
|
||||
expect(resolvePerMethod(cfg, 'heavy')).toEqual({
|
||||
limit: 1,
|
||||
backend: 'redis',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to default for methods not in the map', () => {
|
||||
expect(resolveDriverMethodConcurrent(cfg, 'light')).toEqual({
|
||||
expect(resolvePerMethod(cfg, 'light')).toEqual({
|
||||
limit: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when nothing is declared', () => {
|
||||
expect(
|
||||
resolveDriverMethodConcurrent(undefined, 'anything'),
|
||||
).toBeUndefined();
|
||||
expect(resolveDriverMethodConcurrent({}, 'anything')).toBeUndefined();
|
||||
expect(resolvePerMethod(undefined, 'anything')).toBeUndefined();
|
||||
expect(resolvePerMethod({}, 'anything')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── @Driver — concurrent option ─────────────────────────────────────
|
||||
// ── resolveDriverMeta: concurrent ───────────────────────────────────
|
||||
|
||||
describe('@Driver — concurrent option', () => {
|
||||
it('stamps a validated concurrent block onto the prototype', () => {
|
||||
@Driver('test-iface', {
|
||||
name: 'cdec',
|
||||
concurrent: {
|
||||
default: { limit: 4 },
|
||||
methods: {
|
||||
chat: {
|
||||
limit: 5,
|
||||
bySubscription: { user_free: 1 },
|
||||
backend: 'redis',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
class FakeDriver {}
|
||||
|
||||
const inst = new FakeDriver();
|
||||
const meta = resolveDriverMeta(
|
||||
inst as unknown as Record<string, unknown> & {
|
||||
onServerStart?: () => void;
|
||||
onServerPrepareShutdown?: () => void;
|
||||
onServerShutdown?: () => void;
|
||||
},
|
||||
);
|
||||
expect(meta?.concurrent).toEqual({
|
||||
default: { limit: 4 },
|
||||
methods: {
|
||||
chat: {
|
||||
limit: 5,
|
||||
bySubscription: { user_free: 1 },
|
||||
backend: 'redis',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('throws at decoration time on a malformed concurrent block', () => {
|
||||
expect(() => {
|
||||
@Driver('test-iface', {
|
||||
name: 'bad-concurrent',
|
||||
concurrent: { default: { limit: -1 } },
|
||||
})
|
||||
class BrokenDriver {}
|
||||
void BrokenDriver;
|
||||
}).toThrow(/limit: expected a positive number/);
|
||||
});
|
||||
|
||||
it('falls back to imperative `concurrent` field when decorator metadata is absent', () => {
|
||||
describe('resolveDriverMeta — concurrent', () => {
|
||||
it('reads the `concurrent` field', () => {
|
||||
class Imperative {
|
||||
readonly driverInterface = 'imp-iface';
|
||||
readonly driverName = 'imp-c';
|
||||
@@ -445,10 +360,12 @@ describe('@Driver — concurrent option', () => {
|
||||
|
||||
// ── requireSubscription ─────────────────────────────────────────────
|
||||
|
||||
describe('validateDriverRequireSubscription', () => {
|
||||
describe('validatePerMethod: requireSubscription', () => {
|
||||
it('returns an empty config for null/undefined input', () => {
|
||||
expect(validateDriverRequireSubscription(undefined, 't')).toEqual({});
|
||||
expect(validateDriverRequireSubscription(null, 't')).toEqual({});
|
||||
expect(
|
||||
validatePerMethod(undefined, 't', 'requireSubscription'),
|
||||
).toEqual({});
|
||||
expect(validatePerMethod(null, 't', 'requireSubscription')).toEqual({});
|
||||
});
|
||||
|
||||
it('accepts booleans and id allowlists', () => {
|
||||
@@ -456,70 +373,58 @@ describe('validateDriverRequireSubscription', () => {
|
||||
default: false,
|
||||
methods: { generate: true, generateLong: ['business', 'pro'] },
|
||||
};
|
||||
expect(validateDriverRequireSubscription(cfg, 't')).toBe(cfg);
|
||||
expect(validatePerMethod(cfg, 't', 'requireSubscription')).toBe(cfg);
|
||||
});
|
||||
|
||||
it('rejects a non-object config', () => {
|
||||
expect(() => validateDriverRequireSubscription(42, 't')).toThrow(
|
||||
expect(() => validatePerMethod(42, 't', 'requireSubscription')).toThrow(
|
||||
/requireSubscription must be an object/,
|
||||
);
|
||||
expect(() => validateDriverRequireSubscription([], 't')).toThrow(
|
||||
expect(() => validatePerMethod([], 't', 'requireSubscription')).toThrow(
|
||||
/requireSubscription must be an object/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects requirements that name nothing', () => {
|
||||
expect(() =>
|
||||
validateDriverRequireSubscription({ default: [] }, 't'),
|
||||
validatePerMethod({ default: [] }, 't', 'requireSubscription'),
|
||||
).toThrow(/at least one subscription id/);
|
||||
expect(() =>
|
||||
validateDriverRequireSubscription({ methods: { a: [1] } }, 't'),
|
||||
validatePerMethod(
|
||||
{ methods: { a: [1] } },
|
||||
't',
|
||||
'requireSubscription',
|
||||
),
|
||||
).toThrow(/must be strings/);
|
||||
expect(() =>
|
||||
validateDriverRequireSubscription({ methods: { a: 'pro' } }, 't'),
|
||||
validatePerMethod(
|
||||
{ methods: { a: 'pro' } },
|
||||
't',
|
||||
'requireSubscription',
|
||||
),
|
||||
).toThrow(/expected true\/false or an array of ids/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDriverMethodRequireSubscription', () => {
|
||||
describe('resolvePerMethod: requireSubscription', () => {
|
||||
const cfg: DriverRequireSubscriptionConfig = {
|
||||
default: true,
|
||||
methods: { list: false, generateLong: ['pro'] },
|
||||
};
|
||||
|
||||
it('prefers a per-method entry over the default', () => {
|
||||
expect(resolveDriverMethodRequireSubscription(cfg, 'list')).toBe(false);
|
||||
expect(
|
||||
resolveDriverMethodRequireSubscription(cfg, 'generateLong'),
|
||||
).toEqual(['pro']);
|
||||
expect(resolvePerMethod(cfg, 'list')).toBe(false);
|
||||
expect(resolvePerMethod(cfg, 'generateLong')).toEqual(['pro']);
|
||||
});
|
||||
|
||||
it('falls back to the default, and to undefined with no config', () => {
|
||||
expect(resolveDriverMethodRequireSubscription(cfg, 'generate')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
resolveDriverMethodRequireSubscription(undefined, 'generate'),
|
||||
).toBeUndefined();
|
||||
expect(resolveDriverMethodRequireSubscription({}, 'generate')).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(resolvePerMethod(cfg, 'generate')).toBe(true);
|
||||
expect(resolvePerMethod(undefined, 'generate')).toBeUndefined();
|
||||
expect(resolvePerMethod({}, 'generate')).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDriverMeta — requireSubscription', () => {
|
||||
it('reads the block off the decorator', () => {
|
||||
@Driver('test-iface', {
|
||||
name: 'decorated',
|
||||
requireSubscription: { methods: { generate: true } },
|
||||
})
|
||||
class Decorated {}
|
||||
|
||||
expect(resolveDriverMeta(new Decorated() as never)).toMatchObject({
|
||||
requireSubscription: { methods: { generate: true } },
|
||||
});
|
||||
});
|
||||
|
||||
it('validates an imperatively declared block', () => {
|
||||
const driver = {
|
||||
driverInterface: 'test-iface',
|
||||
@@ -549,10 +454,12 @@ describe('resolveDriverMeta — requireSubscription', () => {
|
||||
|
||||
// ── requireReputation ───────────────────────────────────────────────
|
||||
|
||||
describe('validateDriverRequireReputation', () => {
|
||||
describe('validatePerMethod: requireReputation', () => {
|
||||
it('returns an empty config for null/undefined input', () => {
|
||||
expect(validateDriverRequireReputation(undefined, 't')).toEqual({});
|
||||
expect(validateDriverRequireReputation(null, 't')).toEqual({});
|
||||
expect(validatePerMethod(undefined, 't', 'requireReputation')).toEqual(
|
||||
{},
|
||||
);
|
||||
expect(validatePerMethod(null, 't', 'requireReputation')).toEqual({});
|
||||
});
|
||||
|
||||
it('accepts tier names and explicit opt-outs', () => {
|
||||
@@ -560,70 +467,54 @@ describe('validateDriverRequireReputation', () => {
|
||||
default: false,
|
||||
methods: { generate: 'standard' },
|
||||
};
|
||||
expect(validateDriverRequireReputation(cfg, 't')).toBe(cfg);
|
||||
expect(validatePerMethod(cfg, 't', 'requireReputation')).toBe(cfg);
|
||||
});
|
||||
|
||||
it('rejects a non-object config', () => {
|
||||
expect(() => validateDriverRequireReputation(42, 't')).toThrow(
|
||||
expect(() => validatePerMethod(42, 't', 'requireReputation')).toThrow(
|
||||
/requireReputation must be an object/,
|
||||
);
|
||||
expect(() => validateDriverRequireReputation([], 't')).toThrow(
|
||||
expect(() => validatePerMethod([], 't', 'requireReputation')).toThrow(
|
||||
/requireReputation must be an object/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects requirements that name no tier', () => {
|
||||
expect(() =>
|
||||
validateDriverRequireReputation({ default: '' }, 't'),
|
||||
validatePerMethod({ default: '' }, 't', 'requireReputation'),
|
||||
).toThrow(/non-empty tier name/);
|
||||
expect(() =>
|
||||
validateDriverRequireReputation({ methods: { a: true } }, 't'),
|
||||
validatePerMethod(
|
||||
{ methods: { a: true } },
|
||||
't',
|
||||
'requireReputation',
|
||||
),
|
||||
).toThrow(/expected a tier name, or false/);
|
||||
expect(() =>
|
||||
validateDriverRequireReputation({ methods: { a: 60 } }, 't'),
|
||||
validatePerMethod({ methods: { a: 60 } }, 't', 'requireReputation'),
|
||||
).toThrow(/expected a tier name, or false/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDriverMethodRequireReputation', () => {
|
||||
describe('resolvePerMethod: requireReputation', () => {
|
||||
const cfg: DriverRequireReputationConfig = {
|
||||
default: 'standard',
|
||||
methods: { list: false, generateLong: 'trusted' },
|
||||
};
|
||||
|
||||
it('prefers a per-method entry over the default', () => {
|
||||
expect(resolveDriverMethodRequireReputation(cfg, 'list')).toBe(false);
|
||||
expect(resolveDriverMethodRequireReputation(cfg, 'generateLong')).toBe(
|
||||
'trusted',
|
||||
);
|
||||
expect(resolvePerMethod(cfg, 'list')).toBe(false);
|
||||
expect(resolvePerMethod(cfg, 'generateLong')).toBe('trusted');
|
||||
});
|
||||
|
||||
it('falls back to the default, and to undefined with no config', () => {
|
||||
expect(resolveDriverMethodRequireReputation(cfg, 'generate')).toBe(
|
||||
'standard',
|
||||
);
|
||||
expect(
|
||||
resolveDriverMethodRequireReputation(undefined, 'generate'),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
resolveDriverMethodRequireReputation({}, 'generate'),
|
||||
).toBeUndefined();
|
||||
expect(resolvePerMethod(cfg, 'generate')).toBe('standard');
|
||||
expect(resolvePerMethod(undefined, 'generate')).toBeUndefined();
|
||||
expect(resolvePerMethod({}, 'generate')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDriverMeta — requireReputation', () => {
|
||||
it('reads the block off the decorator', () => {
|
||||
@Driver('test-iface', {
|
||||
name: 'decorated-reputation',
|
||||
requireReputation: { methods: { generate: 'standard' } },
|
||||
})
|
||||
class Decorated {}
|
||||
|
||||
expect(resolveDriverMeta(new Decorated() as never)).toMatchObject({
|
||||
requireReputation: { methods: { generate: 'standard' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('validates an imperatively declared block', () => {
|
||||
const driver = {
|
||||
driverInterface: 'test-iface',
|
||||
@@ -650,3 +541,18 @@ describe('resolveDriverMeta — requireReputation', () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deprecated per-method resolver names', () => {
|
||||
it('still resolve, for extensions that import them', () => {
|
||||
const cfg = {
|
||||
default: { limit: 1, window: 1_000 },
|
||||
methods: { send: { limit: 2, window: 1_000 } },
|
||||
};
|
||||
expect(resolveDriverMethodRateLimit(cfg, 'send')).toBe(
|
||||
cfg.methods.send,
|
||||
);
|
||||
expect(
|
||||
resolveDriverMethodConcurrent({ default: { limit: 3 } }, 'x'),
|
||||
).toEqual({ limit: 3 });
|
||||
});
|
||||
});
|
||||
+100
-342
@@ -54,27 +54,18 @@ export function isDriverStreamResult(v: unknown): v is DriverStreamResult {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Driver metadata keys --------------------------------------------
|
||||
// -- Per-method driver policies ----------------------------------------
|
||||
//
|
||||
// Metadata keys stored on driver prototypes by the `@Driver` decorator.
|
||||
// Imperative drivers set these as instance properties instead.
|
||||
// Every policy a driver declares has the same shape: a `default` entry and
|
||||
// per-method overrides. `DriverController` resolves the entry for the called
|
||||
// method, so methods can differ in limits, plan and reputation floor.
|
||||
|
||||
export const DRIVER_INTERFACE_KEY = '__driverInterface' as const;
|
||||
export const DRIVER_NAME_KEY = '__driverName' as const;
|
||||
export const DRIVER_DEFAULT_KEY = '__driverDefault' as const;
|
||||
export const DRIVER_ALIASES_KEY = '__driverAliases' as const;
|
||||
export const DRIVER_RATE_LIMIT_KEY = '__driverRateLimit' as const;
|
||||
export const DRIVER_CONCURRENT_KEY = '__driverConcurrent' as const;
|
||||
export const DRIVER_NO_USER_SESSION_KEY = '__driverNoUserSession' as const;
|
||||
export const DRIVER_REQUIRE_SUBSCRIPTION_KEY =
|
||||
'__driverRequireSubscription' as const;
|
||||
export const DRIVER_REQUIRE_REPUTATION_KEY =
|
||||
'__driverRequireReputation' as const;
|
||||
|
||||
// -- Driver rate-limit config ----------------------------------------
|
||||
//
|
||||
// Declared per driver; `DriverController` resolves the spec for the requested
|
||||
// method, so methods can differ in limits and storage backend.
|
||||
/** A driver policy: `default` for any method not listed in `methods`. */
|
||||
export interface PerMethodConfig<T> {
|
||||
default?: T;
|
||||
/** Keys are driver method names. */
|
||||
methods?: Record<string, T>;
|
||||
}
|
||||
|
||||
export const RATE_LIMIT_BACKEND_NAMES = ['memory', 'redis', 'kv'] as const;
|
||||
export type RateLimitBackend = (typeof RATE_LIMIT_BACKEND_NAMES)[number];
|
||||
@@ -90,42 +81,36 @@ export interface DriverRateLimitSpec {
|
||||
backend?: RateLimitBackend;
|
||||
}
|
||||
|
||||
export interface DriverRateLimitConfig {
|
||||
/** Applied to any method not listed in `methods`. */
|
||||
default?: DriverRateLimitSpec;
|
||||
/** Per-method overrides. Keys are driver method names. */
|
||||
methods?: Record<string, DriverRateLimitSpec>;
|
||||
export interface DriverConcurrentSpec {
|
||||
/** Maximum simultaneous in-flight requests. */
|
||||
limit: number;
|
||||
/** Per-`SubscriptionPolicy.id` overrides for `limit`. */
|
||||
bySubscription?: Record<string, number>;
|
||||
/** `memory` is per-process; use `redis` on multi-node deployments. */
|
||||
backend?: RateLimitBackend;
|
||||
}
|
||||
|
||||
/** Validate a driver's `rateLimit` block; throws at boot on a bad shape. */
|
||||
export function validateDriverRateLimit(
|
||||
export type DriverRateLimitConfig = PerMethodConfig<DriverRateLimitSpec>;
|
||||
export type DriverConcurrentConfig = PerMethodConfig<DriverConcurrentSpec>;
|
||||
/**
|
||||
* Per-method counterpart of `RouteOptions.requireSubscription`: `/drivers/call`
|
||||
* is one shared route, so a route option would apply to every driver at once.
|
||||
* `undefined` for a method means it is open to every plan.
|
||||
*/
|
||||
export type DriverRequireSubscriptionConfig =
|
||||
PerMethodConfig<SubscriptionRequirement>;
|
||||
/**
|
||||
* Per-method counterpart of `RouteOptions.requireReputation`. A tier is only a
|
||||
* name; the score it takes is deployment config.
|
||||
*/
|
||||
export type DriverRequireReputationConfig =
|
||||
PerMethodConfig<ReputationRequirement>;
|
||||
|
||||
function validateLimitSpec(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): DriverRateLimitConfig {
|
||||
if (value == null) return {};
|
||||
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${label}: rateLimit must be an object`);
|
||||
}
|
||||
const cfg = value as Record<string, unknown>;
|
||||
if (cfg.default !== undefined) {
|
||||
validateSpec(cfg.default, `${label}.rateLimit.default`);
|
||||
}
|
||||
if (cfg.methods !== undefined) {
|
||||
if (
|
||||
typeof cfg.methods !== 'object' ||
|
||||
cfg.methods === null ||
|
||||
Array.isArray(cfg.methods)
|
||||
) {
|
||||
throw new Error(`${label}.rateLimit.methods must be an object`);
|
||||
}
|
||||
for (const [name, spec] of Object.entries(cfg.methods)) {
|
||||
validateSpec(spec, `${label}.rateLimit.methods.${name}`);
|
||||
}
|
||||
}
|
||||
return cfg as DriverRateLimitConfig;
|
||||
}
|
||||
|
||||
function validateSpec(value: unknown, label: string): void {
|
||||
{ windowed }: { windowed: boolean },
|
||||
): void {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error(`${label}: expected an object`);
|
||||
}
|
||||
@@ -138,9 +123,10 @@ function validateSpec(value: unknown, label: string): void {
|
||||
throw new Error(`${label}.limit: expected a positive number`);
|
||||
}
|
||||
if (
|
||||
typeof spec.window !== 'number' ||
|
||||
!Number.isFinite(spec.window) ||
|
||||
spec.window <= 0
|
||||
windowed &&
|
||||
(typeof spec.window !== 'number' ||
|
||||
!Number.isFinite(spec.window) ||
|
||||
spec.window <= 0)
|
||||
) {
|
||||
throw new Error(`${label}.window: expected a positive number (ms)`);
|
||||
}
|
||||
@@ -172,52 +158,43 @@ function validateBySubscription(value: unknown, label: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the spec that applies to a given method on a driver. Per-method entry
|
||||
* wins over `default`; returns `undefined` if neither is set so the caller can
|
||||
* apply its own fallback.
|
||||
*/
|
||||
export function resolveDriverMethodRateLimit(
|
||||
cfg: DriverRateLimitConfig | undefined,
|
||||
method: string,
|
||||
): DriverRateLimitSpec | undefined {
|
||||
if (!cfg) return undefined;
|
||||
return cfg.methods?.[method] ?? cfg.default;
|
||||
}
|
||||
/** The entry validator for each policy a driver may declare. */
|
||||
const POLICY_ENTRY_VALIDATORS = {
|
||||
rateLimit: (value: unknown, label: string) =>
|
||||
validateLimitSpec(value, label, { windowed: true }),
|
||||
concurrent: (value: unknown, label: string) =>
|
||||
validateLimitSpec(value, label, { windowed: false }),
|
||||
requireSubscription: validateSubscriptionRequirement,
|
||||
requireReputation: validateReputationRequirement,
|
||||
};
|
||||
|
||||
// -- Driver concurrent-limit config ----------------------------------
|
||||
export type DriverPolicyName = keyof typeof POLICY_ENTRY_VALIDATORS;
|
||||
|
||||
export interface DriverConcurrentSpec {
|
||||
/** Maximum simultaneous in-flight requests. */
|
||||
limit: number;
|
||||
/** Per-`SubscriptionPolicy.id` overrides for `limit`. */
|
||||
bySubscription?: Record<string, number>;
|
||||
/** `memory` is per-process; use `redis` on multi-node deployments. */
|
||||
backend?: RateLimitBackend;
|
||||
}
|
||||
|
||||
export interface DriverConcurrentConfig {
|
||||
/** Applied to any method not listed in `methods`. */
|
||||
default?: DriverConcurrentSpec;
|
||||
/** Per-method overrides. Keys are driver method names. */
|
||||
methods?: Record<string, DriverConcurrentSpec>;
|
||||
interface DriverPolicyConfigs {
|
||||
rateLimit: DriverRateLimitConfig;
|
||||
concurrent: DriverConcurrentConfig;
|
||||
requireSubscription: DriverRequireSubscriptionConfig;
|
||||
requireReputation: DriverRequireReputationConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a `concurrent` block. Mirrors `validateDriverRateLimit` — throws
|
||||
* with a labelled path so a malformed entry surfaces at boot.
|
||||
* Validate a driver's `policy` block. Throws with a labelled path at
|
||||
* registration, so a malformed entry surfaces at boot rather than on the first
|
||||
* call.
|
||||
*/
|
||||
export function validateDriverConcurrent(
|
||||
export function validatePerMethod<K extends DriverPolicyName>(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): DriverConcurrentConfig {
|
||||
policy: K,
|
||||
): DriverPolicyConfigs[K] {
|
||||
if (value == null) return {};
|
||||
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${label}: concurrent must be an object`);
|
||||
throw new Error(`${label}: ${policy} must be an object`);
|
||||
}
|
||||
const validateEntry = POLICY_ENTRY_VALIDATORS[policy];
|
||||
const cfg = value as Record<string, unknown>;
|
||||
if (cfg.default !== undefined) {
|
||||
validateConcurrentSpec(cfg.default, `${label}.concurrent.default`);
|
||||
validateEntry(cfg.default, `${label}.${policy}.default`);
|
||||
}
|
||||
if (cfg.methods !== undefined) {
|
||||
if (
|
||||
@@ -225,193 +202,38 @@ export function validateDriverConcurrent(
|
||||
cfg.methods === null ||
|
||||
Array.isArray(cfg.methods)
|
||||
) {
|
||||
throw new Error(`${label}.concurrent.methods must be an object`);
|
||||
throw new Error(`${label}.${policy}.methods must be an object`);
|
||||
}
|
||||
for (const [name, spec] of Object.entries(cfg.methods)) {
|
||||
validateConcurrentSpec(spec, `${label}.concurrent.methods.${name}`);
|
||||
for (const [name, entry] of Object.entries(cfg.methods)) {
|
||||
validateEntry(entry, `${label}.${policy}.methods.${name}`);
|
||||
}
|
||||
}
|
||||
return cfg as DriverConcurrentConfig;
|
||||
}
|
||||
|
||||
function validateConcurrentSpec(value: unknown, label: string): void {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error(`${label}: expected an object`);
|
||||
}
|
||||
const spec = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof spec.limit !== 'number' ||
|
||||
!Number.isFinite(spec.limit) ||
|
||||
spec.limit <= 0
|
||||
) {
|
||||
throw new Error(`${label}.limit: expected a positive number`);
|
||||
}
|
||||
if (spec.backend !== undefined) {
|
||||
if (
|
||||
typeof spec.backend !== 'string' ||
|
||||
!RATE_LIMIT_BACKEND_NAMES.includes(spec.backend as RateLimitBackend)
|
||||
) {
|
||||
throw new Error(
|
||||
`${label}.backend: expected one of ${RATE_LIMIT_BACKEND_NAMES.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (spec.bySubscription !== undefined) {
|
||||
validateBySubscription(spec.bySubscription, label);
|
||||
}
|
||||
return cfg as DriverPolicyConfigs[K];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the concurrent spec for a given method on a driver. Same precedence
|
||||
* as `resolveDriverMethodRateLimit`: per-method wins over `default`;
|
||||
* `undefined` means no concurrency cap is declared, and the caller should leave
|
||||
* the method unbounded.
|
||||
* The entry that applies to `method`: its own, else `default`, else `undefined`
|
||||
* (the caller decides what an undeclared policy means).
|
||||
*/
|
||||
export function resolveDriverMethodConcurrent(
|
||||
cfg: DriverConcurrentConfig | undefined,
|
||||
export function resolvePerMethod<T>(
|
||||
cfg: PerMethodConfig<T> | undefined,
|
||||
method: string,
|
||||
): DriverConcurrentSpec | undefined {
|
||||
): T | undefined {
|
||||
if (!cfg) return undefined;
|
||||
return cfg.methods?.[method] ?? cfg.default;
|
||||
}
|
||||
|
||||
// -- Driver subscription requirement ---------------------------------
|
||||
//
|
||||
// Per-method counterpart of `RouteOptions.requireSubscription`. It lives on the
|
||||
// driver for the same reason `noUserSession` does: `/drivers/call` is one
|
||||
// shared route, so a requirement declared in route options would apply to every
|
||||
// driver at once.
|
||||
|
||||
export interface DriverRequireSubscriptionConfig {
|
||||
/** Applied to any method not listed in `methods`. */
|
||||
default?: SubscriptionRequirement;
|
||||
/** Per-method overrides. Keys are driver method names. */
|
||||
methods?: Record<string, SubscriptionRequirement>;
|
||||
}
|
||||
/** @deprecated Use `resolvePerMethod`; kept for extensions still on it. */
|
||||
export const resolveDriverMethodRateLimit =
|
||||
resolvePerMethod<DriverRateLimitSpec>;
|
||||
/** @deprecated Use `resolvePerMethod`; kept for extensions still on it. */
|
||||
export const resolveDriverMethodConcurrent =
|
||||
resolvePerMethod<DriverConcurrentSpec>;
|
||||
|
||||
/**
|
||||
* Validate a `requireSubscription` block. Same loud-at-boot contract as the
|
||||
* rate-limit and concurrent validators.
|
||||
*/
|
||||
export function validateDriverRequireSubscription(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): DriverRequireSubscriptionConfig {
|
||||
if (value == null) return {};
|
||||
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${label}: requireSubscription must be an object`);
|
||||
}
|
||||
const cfg = value as Record<string, unknown>;
|
||||
if (cfg.default !== undefined) {
|
||||
validateSubscriptionRequirement(
|
||||
cfg.default,
|
||||
`${label}.requireSubscription.default`,
|
||||
);
|
||||
}
|
||||
if (cfg.methods !== undefined) {
|
||||
if (
|
||||
typeof cfg.methods !== 'object' ||
|
||||
cfg.methods === null ||
|
||||
Array.isArray(cfg.methods)
|
||||
) {
|
||||
throw new Error(
|
||||
`${label}.requireSubscription.methods must be an object`,
|
||||
);
|
||||
}
|
||||
for (const [name, requirement] of Object.entries(cfg.methods)) {
|
||||
validateSubscriptionRequirement(
|
||||
requirement,
|
||||
`${label}.requireSubscription.methods.${name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return cfg as DriverRequireSubscriptionConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the subscription requirement for a method. Same precedence as the
|
||||
* other per-method resolvers: an entry in `methods` wins over `default`, and
|
||||
* `undefined` means the method is open to every plan.
|
||||
*/
|
||||
export function resolveDriverMethodRequireSubscription(
|
||||
cfg: DriverRequireSubscriptionConfig | undefined,
|
||||
method: string,
|
||||
): SubscriptionRequirement | undefined {
|
||||
if (!cfg) return undefined;
|
||||
return cfg.methods?.[method] ?? cfg.default;
|
||||
}
|
||||
|
||||
// -- Driver reputation requirement -----------------------------------
|
||||
//
|
||||
// Per-method counterpart of `RouteOptions.requireReputation`, on the driver
|
||||
// for the same reason the subscription block is: `/drivers/call` is one shared
|
||||
// route, so a requirement in route options would apply to every driver at
|
||||
// once. The tier named here is only a name — the score it takes is deployment
|
||||
// config, so a driver never carries the number it is worth.
|
||||
|
||||
export interface DriverRequireReputationConfig {
|
||||
/** Applied to any method not listed in `methods`. */
|
||||
default?: ReputationRequirement;
|
||||
/** Per-method overrides. Keys are driver method names. */
|
||||
methods?: Record<string, ReputationRequirement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a `requireReputation` block. Same loud-at-boot contract as the
|
||||
* rate-limit, concurrent, and subscription validators.
|
||||
*/
|
||||
export function validateDriverRequireReputation(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): DriverRequireReputationConfig {
|
||||
if (value == null) return {};
|
||||
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${label}: requireReputation must be an object`);
|
||||
}
|
||||
const cfg = value as Record<string, unknown>;
|
||||
if (cfg.default !== undefined) {
|
||||
validateReputationRequirement(
|
||||
cfg.default,
|
||||
`${label}.requireReputation.default`,
|
||||
);
|
||||
}
|
||||
if (cfg.methods !== undefined) {
|
||||
if (
|
||||
typeof cfg.methods !== 'object' ||
|
||||
cfg.methods === null ||
|
||||
Array.isArray(cfg.methods)
|
||||
) {
|
||||
throw new Error(
|
||||
`${label}.requireReputation.methods must be an object`,
|
||||
);
|
||||
}
|
||||
for (const [name, requirement] of Object.entries(cfg.methods)) {
|
||||
validateReputationRequirement(
|
||||
requirement,
|
||||
`${label}.requireReputation.methods.${name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return cfg as DriverRequireReputationConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the reputation requirement for a method. Same precedence as the other
|
||||
* per-method resolvers: an entry in `methods` wins over `default`, and
|
||||
* `undefined` means the method asks for no floor.
|
||||
*/
|
||||
export function resolveDriverMethodRequireReputation(
|
||||
cfg: DriverRequireReputationConfig | undefined,
|
||||
method: string,
|
||||
): ReputationRequirement | undefined {
|
||||
if (!cfg) return undefined;
|
||||
return cfg.methods?.[method] ?? cfg.default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved metadata for a registered driver, from decorator metadata or
|
||||
* imperative instance properties. The gates live here rather than on a route
|
||||
* because every driver shares the `/drivers/call` dispatch route.
|
||||
* Resolved metadata for a registered driver, read from its instance properties.
|
||||
* The gates live here rather than on a route because every driver shares the
|
||||
* `/drivers/call` dispatch route.
|
||||
*/
|
||||
export interface DriverMeta {
|
||||
/** E.g. 'puter-chat-completion'. */
|
||||
@@ -441,97 +263,33 @@ export interface DriverMeta {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract driver metadata from a driver instance. Checks decorator-set
|
||||
* prototype metadata first, then falls back to instance properties. Returns
|
||||
* `null` if the driver doesn't declare an interface.
|
||||
* Extract a driver's metadata, validating its policy blocks so a malformed one
|
||||
* fails loud at registration. Returns `null` if the driver doesn't declare an
|
||||
* interface and name.
|
||||
*/
|
||||
export function resolveDriverMeta(
|
||||
driver: WithLifecycle & Record<string, unknown>,
|
||||
): DriverMeta | null {
|
||||
const proto = Object.getPrototypeOf(driver) as Record<string, unknown>;
|
||||
|
||||
const interfaceName =
|
||||
(proto[DRIVER_INTERFACE_KEY] as string | undefined) ??
|
||||
(driver.driverInterface as string | undefined);
|
||||
const driverName =
|
||||
(proto[DRIVER_NAME_KEY] as string | undefined) ??
|
||||
(driver.driverName as string | undefined);
|
||||
const isDefault =
|
||||
(proto[DRIVER_DEFAULT_KEY] as boolean | undefined) ??
|
||||
(driver.isDefault as boolean | undefined) ??
|
||||
false;
|
||||
const aliases =
|
||||
(proto[DRIVER_ALIASES_KEY] as string[] | undefined) ??
|
||||
(driver.driverAliases as string[] | undefined) ??
|
||||
[];
|
||||
// Decorator stashes a validated config on the prototype; imperative
|
||||
// drivers declare a raw object on the instance, which we validate here
|
||||
// so a malformed `rateLimit` field still fails loud at registration.
|
||||
const protoRateLimit = proto[DRIVER_RATE_LIMIT_KEY] as
|
||||
DriverRateLimitConfig | undefined;
|
||||
let rateLimit: DriverRateLimitConfig | undefined;
|
||||
if (protoRateLimit) {
|
||||
rateLimit = protoRateLimit;
|
||||
} else if (driver.rateLimit !== undefined) {
|
||||
rateLimit = validateDriverRateLimit(
|
||||
driver.rateLimit,
|
||||
`driver '${driverName ?? '(unnamed)'}'`,
|
||||
);
|
||||
}
|
||||
|
||||
const protoConcurrent = proto[DRIVER_CONCURRENT_KEY] as
|
||||
DriverConcurrentConfig | undefined;
|
||||
let concurrent: DriverConcurrentConfig | undefined;
|
||||
if (protoConcurrent) {
|
||||
concurrent = protoConcurrent;
|
||||
} else if (driver.concurrent !== undefined) {
|
||||
concurrent = validateDriverConcurrent(
|
||||
driver.concurrent,
|
||||
`driver '${driverName ?? '(unnamed)'}'`,
|
||||
);
|
||||
}
|
||||
|
||||
const protoRequireSubscription = proto[DRIVER_REQUIRE_SUBSCRIPTION_KEY] as
|
||||
DriverRequireSubscriptionConfig | undefined;
|
||||
let requireSubscription: DriverRequireSubscriptionConfig | undefined;
|
||||
if (protoRequireSubscription) {
|
||||
requireSubscription = protoRequireSubscription;
|
||||
} else if (driver.requireSubscription !== undefined) {
|
||||
requireSubscription = validateDriverRequireSubscription(
|
||||
driver.requireSubscription,
|
||||
`driver '${driverName ?? '(unnamed)'}'`,
|
||||
);
|
||||
}
|
||||
|
||||
const protoRequireReputation = proto[DRIVER_REQUIRE_REPUTATION_KEY] as
|
||||
DriverRequireReputationConfig | undefined;
|
||||
let requireReputation: DriverRequireReputationConfig | undefined;
|
||||
if (protoRequireReputation) {
|
||||
requireReputation = protoRequireReputation;
|
||||
} else if (driver.requireReputation !== undefined) {
|
||||
requireReputation = validateDriverRequireReputation(
|
||||
driver.requireReputation,
|
||||
`driver '${driverName ?? '(unnamed)'}'`,
|
||||
);
|
||||
}
|
||||
|
||||
const noUserSession =
|
||||
(proto[DRIVER_NO_USER_SESSION_KEY] as boolean | undefined) ??
|
||||
(driver.noUserSession as boolean | undefined) ??
|
||||
false;
|
||||
|
||||
const interfaceName = driver.driverInterface as string | undefined;
|
||||
const driverName = driver.driverName as string | undefined;
|
||||
if (!interfaceName || !driverName) return null;
|
||||
|
||||
const label = `driver '${driverName}'`;
|
||||
const policy = <K extends DriverPolicyName>(name: K) =>
|
||||
driver[name] === undefined
|
||||
? undefined
|
||||
: validatePerMethod(driver[name], label, name);
|
||||
|
||||
return {
|
||||
interfaceName,
|
||||
driverName,
|
||||
isDefault,
|
||||
aliases,
|
||||
rateLimit,
|
||||
concurrent,
|
||||
noUserSession,
|
||||
requireSubscription,
|
||||
requireReputation,
|
||||
isDefault: (driver.isDefault as boolean | undefined) ?? false,
|
||||
aliases: (driver.driverAliases as string[] | undefined) ?? [],
|
||||
rateLimit: policy('rateLimit'),
|
||||
concurrent: policy('concurrent'),
|
||||
noUserSession: (driver.noUserSession as boolean | undefined) ?? false,
|
||||
requireSubscription: policy('requireSubscription'),
|
||||
requireReputation: policy('requireReputation'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -47,41 +47,27 @@ export type IPuterDriver<T extends WithCostsReporting = WithCostsReporting> =
|
||||
|
||||
/**
|
||||
* Base class for drivers. A driver implements a named interface (e.g.
|
||||
* `puter-chat-completion`); several drivers may implement the same one. Declare
|
||||
* it with `@Driver(interface, options)` or by setting the readonly fields below
|
||||
* imperatively.
|
||||
* `puter-chat-completion`); several drivers may implement the same one, and
|
||||
* declares itself through the readonly fields below.
|
||||
*/
|
||||
export const PuterDriver = class PuterDriver implements WithCostsReporting {
|
||||
/** The interface this driver implements. Set by `@Driver` or override. */
|
||||
/** The interface this driver implements. */
|
||||
declare readonly driverInterface?: string;
|
||||
/** Unique name within its interface. Set by `@Driver` or override. */
|
||||
/** Unique name within its interface. */
|
||||
declare readonly driverName?: string;
|
||||
/** When true, this is the default driver for its interface. */
|
||||
declare readonly isDefault?: boolean;
|
||||
/**
|
||||
* Rate-limit policy applied to RPC calls into this driver. Set by
|
||||
* `@Driver({ rateLimit: ... })` or declared imperatively. See
|
||||
* `DriverRateLimitConfig` in `./meta` for the shape.
|
||||
*/
|
||||
/** Rate-limit policy for RPC calls into this driver; see `./meta`. */
|
||||
declare readonly rateLimit?: DriverRateLimitConfig;
|
||||
/**
|
||||
* Concurrent in-flight policy applied to RPC calls into this driver. Set by
|
||||
* `@Driver({ concurrent: ... })` or declared imperatively. See
|
||||
* `DriverConcurrentConfig` in `./meta` for the shape.
|
||||
*/
|
||||
/** In-flight cap for RPC calls into this driver; see `./meta`. */
|
||||
declare readonly concurrent?: DriverConcurrentConfig;
|
||||
/**
|
||||
* When true, `/drivers/call` rejects bare account-session ("root") tokens
|
||||
* for this driver — callers need an app/worker token or a dashboard-minted
|
||||
* API token. Set by `@Driver({ noUserSession: true })` or declared
|
||||
* imperatively. See `DriverMeta.noUserSession` in `./meta`.
|
||||
* API token. See `DriverMeta.noUserSession` in `./meta`.
|
||||
*/
|
||||
declare readonly noUserSession?: boolean;
|
||||
/**
|
||||
* Subscriber-only methods on this driver. Set by `@Driver({
|
||||
* requireSubscription: ... })` or declared imperatively. See
|
||||
* `DriverRequireSubscriptionConfig` in `./meta` for the shape.
|
||||
*/
|
||||
/** Subscriber-only methods on this driver; see `./meta`. */
|
||||
declare readonly requireSubscription?: DriverRequireSubscriptionConfig;
|
||||
|
||||
constructor(
|
||||
|
||||
@@ -22,12 +22,7 @@ import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
} from '../../services/metering/consts.js';
|
||||
import {
|
||||
resolveDriverMethodConcurrent,
|
||||
resolveDriverMethodRateLimit,
|
||||
validateDriverConcurrent,
|
||||
validateDriverRateLimit,
|
||||
} from '../meta.js';
|
||||
import { resolvePerMethod, validatePerMethod } from '../meta.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from './aiLimits.js';
|
||||
|
||||
// The shared AI policy is consumed verbatim by 8 drivers — these tests
|
||||
@@ -46,16 +41,16 @@ describe('AI_RATE_LIMIT', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the same validator the @Driver decorator runs at boot', () => {
|
||||
it('passes the validator drivers run at boot', () => {
|
||||
// If validation ever tightens, the AI policy must keep up.
|
||||
expect(() =>
|
||||
validateDriverRateLimit(AI_RATE_LIMIT, 'AI_RATE_LIMIT'),
|
||||
validatePerMethod(AI_RATE_LIMIT, 'AI_RATE_LIMIT', 'rateLimit'),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('resolves the same spec for any method since only `default` is set', () => {
|
||||
const a = resolveDriverMethodRateLimit(AI_RATE_LIMIT, 'complete');
|
||||
const b = resolveDriverMethodRateLimit(AI_RATE_LIMIT, 'generate');
|
||||
const a = resolvePerMethod(AI_RATE_LIMIT, 'complete');
|
||||
const b = resolvePerMethod(AI_RATE_LIMIT, 'generate');
|
||||
expect(a).toEqual(b);
|
||||
expect(a).toBe(AI_RATE_LIMIT.default);
|
||||
});
|
||||
@@ -83,17 +78,17 @@ describe('AI_CONCURRENT', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the same validator the @Driver decorator runs at boot', () => {
|
||||
it('passes the validator drivers run at boot', () => {
|
||||
expect(() =>
|
||||
validateDriverConcurrent(AI_CONCURRENT, 'AI_CONCURRENT'),
|
||||
validatePerMethod(AI_CONCURRENT, 'AI_CONCURRENT', 'concurrent'),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('resolves the same spec for any method since only `default` is set', () => {
|
||||
expect(resolveDriverMethodConcurrent(AI_CONCURRENT, 'complete')).toBe(
|
||||
expect(resolvePerMethod(AI_CONCURRENT, 'complete')).toBe(
|
||||
AI_CONCURRENT.default,
|
||||
);
|
||||
expect(resolveDriverMethodConcurrent(AI_CONCURRENT, 'generate')).toBe(
|
||||
expect(resolvePerMethod(AI_CONCURRENT, 'generate')).toBe(
|
||||
AI_CONCURRENT.default,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user