feat(core-shared): add withRateLimit wrapper and conformance enforcement
- Add withRateLimit(rateLimit, fn) in rate-limit/with-rate-limit.ts, attaching the RateLimited brand at DI bind time - Extend wireUseCase to accept optional rateLimit?: IRateLimit and compose withRateLimit innermost (before analytics/audit); propagate __rateLimited through analytics + audit inline wrappers - Extend withSpan and withCapture PROPAGATED_BRANDS to include __rateLimited so the outermost binding carries the brand - Extend assertFeatureConformance to require __rateLimited brand when manifest.useCases[name].rateLimit.length > 0; refactored into helper functions to stay within complexity thresholds - Add rateLimit?: IRateLimit to BindContext; default to NoopRateLimit in web-next bindAllProduction and bindAllDevSeed aggregators - Unit tests for withRateLimit brand attachment, factory passthrough, and composition; synthetic fixture tests for conformance errors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import { attachBrand } from "@/conformance/brand-runtime";
|
||||
import type { ITracer, ISpan } from "@/instrumentation/tracer.interface";
|
||||
import type { ILogger } from "@/instrumentation/logger.interface";
|
||||
import type { BindContext } from "@/di/bind-context";
|
||||
import type { RateLimitBudget } from "@/rate-limit/rate-limit.interface";
|
||||
|
||||
function makeTracer(): ITracer {
|
||||
return {
|
||||
@@ -396,4 +397,126 @@ describe("assertFeatureConformance", () => {
|
||||
assertFeatureConformance(container, manifest, { signIn: sym }, ctx),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws when use case declares rateLimit but binding is missing __rateLimited brand", () => {
|
||||
const container = new Container();
|
||||
const sym = Symbol("test.signIn");
|
||||
const ctx = makeCtx();
|
||||
const wrappedNoRateLimit = withSpan(
|
||||
ctx.tracer,
|
||||
{ name: "test.signIn", op: "use-case" },
|
||||
withCapture(
|
||||
ctx.logger,
|
||||
{ feature: "test", layer: "use-case" },
|
||||
async (x: number) => x,
|
||||
),
|
||||
);
|
||||
container.bind(sym).toConstantValue(wrappedNoRateLimit);
|
||||
|
||||
const rateLimitBudget: RateLimitBudget = {
|
||||
name: "global",
|
||||
window: "1m",
|
||||
budget: 60,
|
||||
};
|
||||
const manifest = defineFeature({
|
||||
name: "test",
|
||||
requiredCores: [],
|
||||
useCases: {
|
||||
signIn: {
|
||||
mutates: false,
|
||||
audits: [],
|
||||
publishes: [],
|
||||
consumes: [],
|
||||
rateLimit: [rateLimitBudget],
|
||||
},
|
||||
},
|
||||
realtimeChannels: [],
|
||||
jobs: [],
|
||||
} as const);
|
||||
|
||||
expect(() =>
|
||||
assertFeatureConformance(container, manifest, { signIn: sym }, ctx),
|
||||
).toThrow(ConformanceError);
|
||||
expect(() =>
|
||||
assertFeatureConformance(container, manifest, { signIn: sym }, ctx),
|
||||
).toThrow(/__rateLimited/);
|
||||
});
|
||||
|
||||
it("passes when use case declares rateLimit and binding carries __rateLimited brand", () => {
|
||||
const container = new Container();
|
||||
const sym = Symbol("test.signIn");
|
||||
const ctx = makeCtx();
|
||||
const base = withSpan(
|
||||
ctx.tracer,
|
||||
{ name: "test.signIn", op: "use-case" },
|
||||
withCapture(
|
||||
ctx.logger,
|
||||
{ feature: "test", layer: "use-case" },
|
||||
async (x: number) => x,
|
||||
),
|
||||
);
|
||||
attachBrand(base, "__rateLimited");
|
||||
container.bind(sym).toConstantValue(base);
|
||||
|
||||
const rateLimitBudget: RateLimitBudget = {
|
||||
name: "global",
|
||||
window: "1m",
|
||||
budget: 60,
|
||||
};
|
||||
const manifest = defineFeature({
|
||||
name: "test",
|
||||
requiredCores: [],
|
||||
useCases: {
|
||||
signIn: {
|
||||
mutates: false,
|
||||
audits: [],
|
||||
publishes: [],
|
||||
consumes: [],
|
||||
rateLimit: [rateLimitBudget],
|
||||
},
|
||||
},
|
||||
realtimeChannels: [],
|
||||
jobs: [],
|
||||
} as const);
|
||||
|
||||
expect(() =>
|
||||
assertFeatureConformance(container, manifest, { signIn: sym }, ctx),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("passes when rateLimit is empty and __rateLimited brand is absent", () => {
|
||||
const container = new Container();
|
||||
const sym = Symbol("test.signIn");
|
||||
const ctx = makeCtx();
|
||||
const bound = withSpan(
|
||||
ctx.tracer,
|
||||
{ name: "test.signIn", op: "use-case" },
|
||||
withCapture(
|
||||
ctx.logger,
|
||||
{ feature: "test", layer: "use-case" },
|
||||
async (x: number) => x,
|
||||
),
|
||||
);
|
||||
container.bind(sym).toConstantValue(bound);
|
||||
|
||||
const manifest = defineFeature({
|
||||
name: "test",
|
||||
requiredCores: [],
|
||||
useCases: {
|
||||
signIn: {
|
||||
mutates: false,
|
||||
audits: [],
|
||||
publishes: [],
|
||||
consumes: [],
|
||||
rateLimit: [],
|
||||
},
|
||||
},
|
||||
realtimeChannels: [],
|
||||
jobs: [],
|
||||
} as const);
|
||||
|
||||
expect(() =>
|
||||
assertFeatureConformance(container, manifest, { signIn: sym }, ctx),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,66 @@
|
||||
import type { Container } from "inversify";
|
||||
import type { FeatureManifest } from "./define-feature";
|
||||
import type { FeatureManifest, UseCaseManifest } from "./define-feature";
|
||||
import {
|
||||
isInstrumented,
|
||||
isCaptured,
|
||||
isAudited,
|
||||
isAnalyzed,
|
||||
isConsentChecked,
|
||||
isRateLimited,
|
||||
} from "./brand-runtime";
|
||||
import { ConformanceError } from "./conformance-error";
|
||||
import type { BindContext } from "../di/bind-context";
|
||||
|
||||
function requireBrand(ok: boolean, label: string, message: string): void {
|
||||
if (!ok) throw new ConformanceError(`${label}: ${message}`);
|
||||
}
|
||||
|
||||
function checkUseCaseBrands(
|
||||
bound: unknown,
|
||||
label: string,
|
||||
useCase: UseCaseManifest,
|
||||
requiresConsent: boolean,
|
||||
): void {
|
||||
requireBrand(
|
||||
isInstrumented(bound),
|
||||
label,
|
||||
"missing __instrumented brand — was withSpan applied at bind time?",
|
||||
);
|
||||
requireBrand(
|
||||
isCaptured(bound),
|
||||
label,
|
||||
"missing __captured brand — was withCapture applied at bind time?",
|
||||
);
|
||||
if (useCase.mutates && useCase.audits.length > 0) {
|
||||
requireBrand(
|
||||
isAudited(bound),
|
||||
label,
|
||||
"declares audits but binding is missing __audited brand — was withAudit applied at bind time?",
|
||||
);
|
||||
}
|
||||
if ((useCase.analyticsEvents?.length ?? 0) > 0) {
|
||||
requireBrand(
|
||||
isAnalyzed(bound),
|
||||
label,
|
||||
"declares analyticsEvents but binding is missing __analyzed brand — was withAnalytics applied at bind time?",
|
||||
);
|
||||
}
|
||||
if (requiresConsent) {
|
||||
requireBrand(
|
||||
isConsentChecked(bound),
|
||||
label,
|
||||
"feature declares requiresConsent but binding is missing __consentChecked brand — was withConsent applied at bind time?",
|
||||
);
|
||||
}
|
||||
if ((useCase.rateLimit?.length ?? 0) > 0) {
|
||||
requireBrand(
|
||||
isRateLimited(bound),
|
||||
label,
|
||||
"declares rateLimit but binding is missing __rateLimited brand — was withRateLimit applied at bind time?",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime check that every manifest-declared use case is bound through the
|
||||
* brand-attaching wrappers (`withSpan` → `__instrumented`,
|
||||
@@ -32,6 +83,7 @@ export function assertFeatureConformance(
|
||||
_ctx: BindContext,
|
||||
): void {
|
||||
void _ctx; // future: also check ctx.bus / ctx.auditLog presence vs requiredCores
|
||||
const requiresConsent = (manifest.requiresConsent?.length ?? 0) > 0;
|
||||
for (const [name, useCase] of Object.entries(manifest.useCases)) {
|
||||
const sym = symbols[name];
|
||||
if (!sym) {
|
||||
@@ -47,36 +99,11 @@ export function assertFeatureConformance(
|
||||
`${manifest.name}.${name}: container could not resolve symbol (${String(cause)})`,
|
||||
);
|
||||
}
|
||||
if (!isInstrumented(bound)) {
|
||||
throw new ConformanceError(
|
||||
`${manifest.name}.${name}: missing __instrumented brand — was withSpan applied at bind time?`,
|
||||
);
|
||||
}
|
||||
if (!isCaptured(bound)) {
|
||||
throw new ConformanceError(
|
||||
`${manifest.name}.${name}: missing __captured brand — was withCapture applied at bind time?`,
|
||||
);
|
||||
}
|
||||
if (useCase.mutates && useCase.audits.length > 0) {
|
||||
if (!isAudited(bound)) {
|
||||
throw new ConformanceError(
|
||||
`${manifest.name}.${name}: declares audits but binding is missing __audited brand — was withAudit applied at bind time?`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if ((useCase.analyticsEvents?.length ?? 0) > 0) {
|
||||
if (!isAnalyzed(bound)) {
|
||||
throw new ConformanceError(
|
||||
`${manifest.name}.${name}: declares analyticsEvents but binding is missing __analyzed brand — was withAnalytics applied at bind time?`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if ((manifest.requiresConsent?.length ?? 0) > 0) {
|
||||
if (!isConsentChecked(bound)) {
|
||||
throw new ConformanceError(
|
||||
`${manifest.name}.${name}: feature declares requiresConsent but binding is missing __consentChecked brand — was withConsent applied at bind time?`,
|
||||
);
|
||||
}
|
||||
}
|
||||
checkUseCaseBrands(
|
||||
bound,
|
||||
`${manifest.name}.${name}`,
|
||||
useCase,
|
||||
requiresConsent,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
isCaptured,
|
||||
isAudited,
|
||||
isAnalyzed,
|
||||
isRateLimited,
|
||||
} from "@/conformance/brand-runtime";
|
||||
import type {
|
||||
ITracer,
|
||||
@@ -15,6 +16,7 @@ import type {
|
||||
} from "@/instrumentation/tracer.interface";
|
||||
import type { ILogger } from "@/instrumentation/logger.interface";
|
||||
import type { AuditLogProtocol, AnalyticsProtocol } from "@/di/bind-protocols";
|
||||
import { NoopRateLimit } from "@/rate-limit/noop-rate-limit";
|
||||
|
||||
function makeTracer() {
|
||||
const calls: SpanOpts[] = [];
|
||||
@@ -381,3 +383,130 @@ describe("wireUseCase — idempotent re-bind", () => {
|
||||
expect(container.get(sym)).toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wireUseCase — rate limit path", () => {
|
||||
it("attaches RateLimited brand when rateLimit is provided", () => {
|
||||
const { tracer } = makeTracer();
|
||||
const logger = makeLogger();
|
||||
const container = new Container();
|
||||
const sym = Symbol("test.double");
|
||||
const rateLimit = new NoopRateLimit();
|
||||
|
||||
const wired = wireUseCase({
|
||||
container,
|
||||
symbol: sym,
|
||||
factory: doubleFactory,
|
||||
deps: [],
|
||||
feature: "test",
|
||||
layer: "use-case",
|
||||
name: "double",
|
||||
tracer,
|
||||
logger,
|
||||
rateLimit,
|
||||
});
|
||||
|
||||
expect(isRateLimited(wired)).toBe(true);
|
||||
expect(isInstrumented(wired)).toBe(true);
|
||||
expect(isCaptured(wired)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not attach RateLimited brand when rateLimit is absent", () => {
|
||||
const { tracer } = makeTracer();
|
||||
const logger = makeLogger();
|
||||
const container = new Container();
|
||||
const sym = Symbol("test.double");
|
||||
|
||||
const wired = wireUseCase({
|
||||
container,
|
||||
symbol: sym,
|
||||
factory: doubleFactory,
|
||||
deps: [],
|
||||
feature: "test",
|
||||
layer: "use-case",
|
||||
name: "double",
|
||||
tracer,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(isRateLimited(wired)).toBe(false);
|
||||
});
|
||||
|
||||
it("executes factory correctly when rateLimit is provided", async () => {
|
||||
const { tracer } = makeTracer();
|
||||
const logger = makeLogger();
|
||||
const container = new Container();
|
||||
const sym = Symbol("test.double");
|
||||
const rateLimit = new NoopRateLimit();
|
||||
|
||||
const wired = wireUseCase({
|
||||
container,
|
||||
symbol: sym,
|
||||
factory: doubleFactory,
|
||||
deps: [],
|
||||
feature: "test",
|
||||
layer: "use-case",
|
||||
name: "double",
|
||||
tracer,
|
||||
logger,
|
||||
rateLimit,
|
||||
});
|
||||
|
||||
await expect(wired(5)).resolves.toBe(10);
|
||||
});
|
||||
|
||||
it("attaches RateLimited + Analyzed brands when both rateLimit and analytics are provided", () => {
|
||||
const { tracer } = makeTracer();
|
||||
const logger = makeLogger();
|
||||
const container = new Container();
|
||||
const sym = Symbol("test.double");
|
||||
const rateLimit = new NoopRateLimit();
|
||||
const analytics = makeAnalytics();
|
||||
|
||||
const wired = wireUseCase({
|
||||
container,
|
||||
symbol: sym,
|
||||
factory: doubleFactory,
|
||||
deps: [],
|
||||
feature: "test",
|
||||
layer: "use-case",
|
||||
name: "double",
|
||||
tracer,
|
||||
logger,
|
||||
rateLimit,
|
||||
analytics,
|
||||
});
|
||||
|
||||
expect(isRateLimited(wired)).toBe(true);
|
||||
expect(isAnalyzed(wired)).toBe(true);
|
||||
expect(isInstrumented(wired)).toBe(true);
|
||||
expect(isCaptured(wired)).toBe(true);
|
||||
});
|
||||
|
||||
it("attaches RateLimited + Audited brands when both rateLimit and auditLog are provided", () => {
|
||||
const { tracer } = makeTracer();
|
||||
const logger = makeLogger();
|
||||
const container = new Container();
|
||||
const sym = Symbol("test.double");
|
||||
const rateLimit = new NoopRateLimit();
|
||||
const auditLog = makeAuditLog();
|
||||
|
||||
const wired = wireUseCase({
|
||||
container,
|
||||
symbol: sym,
|
||||
factory: doubleFactory,
|
||||
deps: [],
|
||||
feature: "test",
|
||||
layer: "use-case",
|
||||
name: "double",
|
||||
tracer,
|
||||
logger,
|
||||
rateLimit,
|
||||
auditLog,
|
||||
});
|
||||
|
||||
expect(isRateLimited(wired)).toBe(true);
|
||||
expect(isAudited(wired)).toBe(true);
|
||||
expect(isInstrumented(wired)).toBe(true);
|
||||
expect(isCaptured(wired)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,10 @@ import type { Container } from "inversify";
|
||||
import type { ITracer } from "../instrumentation/tracer.interface";
|
||||
import type { ILogger } from "../instrumentation/logger.interface";
|
||||
import type { AuditLogProtocol, AnalyticsProtocol } from "../di/bind-protocols";
|
||||
import type { IRateLimit } from "../rate-limit/rate-limit.interface";
|
||||
import { withSpan } from "../instrumentation/with-span";
|
||||
import { withCapture } from "../instrumentation/with-capture";
|
||||
import { withRateLimit } from "../rate-limit/with-rate-limit";
|
||||
import { attachBrand } from "./brand-runtime";
|
||||
|
||||
export type WireUseCaseOptions<
|
||||
@@ -22,6 +24,7 @@ export type WireUseCaseOptions<
|
||||
logger: ILogger;
|
||||
analytics?: AnalyticsProtocol;
|
||||
auditLog?: AuditLogProtocol;
|
||||
rateLimit?: IRateLimit;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -53,6 +56,7 @@ export function wireUseCase<
|
||||
logger,
|
||||
analytics,
|
||||
auditLog,
|
||||
rateLimit,
|
||||
} = opts;
|
||||
|
||||
const spanName = `${feature}.${name}`;
|
||||
@@ -60,17 +64,27 @@ export function wireUseCase<
|
||||
|
||||
const raw = factory(...deps);
|
||||
|
||||
let toWrap: (...args: FnArgs) => Promise<R>;
|
||||
// analytics is innermost — wraps raw before audit. withAnalytics lives in
|
||||
// rateLimit is innermost — wraps raw before analytics/audit. Attaches
|
||||
// __rateLimited so withCapture + withSpan can propagate it to the outermost binding.
|
||||
let toWrap: (...args: FnArgs) => Promise<R> =
|
||||
rateLimit !== undefined ? withRateLimit(rateLimit, raw) : raw;
|
||||
|
||||
// analytics wraps rateLimit (or raw) before audit. withAnalytics lives in
|
||||
// @repo/core-analytics which depends on core-shared (not vice versa), so we
|
||||
// replicate the forwarding-wrapper semantics inline to avoid a circular dep.
|
||||
if (analytics !== undefined) {
|
||||
void analytics; // reserved for future automated event recording from manifest declarations
|
||||
const analyzed: (...args: FnArgs) => Promise<R> = (...args) => raw(...args);
|
||||
const prev = toWrap;
|
||||
const analyzed: (...args: FnArgs) => Promise<R> = (...args) =>
|
||||
prev(...args);
|
||||
attachBrand(analyzed, "__analyzed");
|
||||
// propagate __rateLimited from inner so withCapture/withSpan see it
|
||||
if (
|
||||
(prev as unknown as Record<string, unknown>)["__rateLimited"] === true
|
||||
) {
|
||||
attachBrand(analyzed, "__rateLimited");
|
||||
}
|
||||
toWrap = analyzed;
|
||||
} else {
|
||||
toWrap = raw;
|
||||
}
|
||||
if (auditLog !== undefined) {
|
||||
void auditLog; // reserved for future automated audit recording from manifest declarations
|
||||
@@ -78,11 +92,16 @@ export function wireUseCase<
|
||||
const prev = toWrap;
|
||||
const audited: (...args: FnArgs) => Promise<R> = (...args) => prev(...args);
|
||||
attachBrand(audited, "__audited");
|
||||
// propagate __analyzed from the analytics layer below so withCapture/withSpan
|
||||
// can see it on the outermost binding
|
||||
// propagate __analyzed and __rateLimited from inner so withCapture/withSpan
|
||||
// can see them on the outermost binding
|
||||
if ((prev as unknown as Record<string, unknown>)["__analyzed"] === true) {
|
||||
attachBrand(audited, "__analyzed");
|
||||
}
|
||||
if (
|
||||
(prev as unknown as Record<string, unknown>)["__rateLimited"] === true
|
||||
) {
|
||||
attachBrand(audited, "__rateLimited");
|
||||
}
|
||||
toWrap = audited;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import type { ITracer, ILogger } from "../instrumentation";
|
||||
import type { IJobQueue } from "../jobs";
|
||||
import type { IRateLimit } from "../rate-limit/rate-limit.interface";
|
||||
import type {
|
||||
EventBusProtocol,
|
||||
RealtimeBroadcasterProtocol,
|
||||
@@ -45,6 +46,7 @@ export type BindContext<
|
||||
auditLog?: Audit;
|
||||
analytics?: Analytics;
|
||||
consentFactory?: ConsentFactoryProtocol;
|
||||
rateLimit?: IRateLimit;
|
||||
};
|
||||
|
||||
/** Production binders also receive the resolved Payload config. */
|
||||
|
||||
@@ -33,6 +33,7 @@ export function withCapture<Args extends unknown[], R>(
|
||||
"__audited",
|
||||
"__analyzed",
|
||||
"__consentChecked",
|
||||
"__rateLimited",
|
||||
] as const;
|
||||
|
||||
const wrapped: (...args: Args) => Promise<R> = async (...args) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ const PROPAGATED_BRANDS = [
|
||||
"__audited",
|
||||
"__analyzed",
|
||||
"__consentChecked",
|
||||
"__rateLimited",
|
||||
] as const;
|
||||
|
||||
export function withSpan<Args extends unknown[], R, Extra extends object>(
|
||||
|
||||
@@ -5,3 +5,4 @@ export type {
|
||||
} from "./rate-limit.interface";
|
||||
export { NoopRateLimit } from "./noop-rate-limit";
|
||||
export { InMemoryRateLimit } from "./in-memory-rate-limit";
|
||||
export { withRateLimit } from "./with-rate-limit";
|
||||
|
||||
85
packages/core-shared/src/rate-limit/with-rate-limit.test.ts
Normal file
85
packages/core-shared/src/rate-limit/with-rate-limit.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, expectTypeOf } from "vitest";
|
||||
import { withRateLimit } from "@/rate-limit/with-rate-limit";
|
||||
import {
|
||||
isRateLimited,
|
||||
isInstrumented,
|
||||
isCaptured,
|
||||
} from "@/conformance/brand-runtime";
|
||||
import { attachBrand } from "@/conformance/brand-runtime";
|
||||
import type { RateLimited } from "@/conformance/brands";
|
||||
import { NoopRateLimit } from "@/rate-limit/noop-rate-limit";
|
||||
|
||||
function makeRateLimit() {
|
||||
return new NoopRateLimit();
|
||||
}
|
||||
|
||||
describe("withRateLimit — brand", () => {
|
||||
it("returns a RateLimited<F> type", () => {
|
||||
const rateLimit = makeRateLimit();
|
||||
const fn = async (a: number) => a + 1;
|
||||
const wrapped = withRateLimit(rateLimit, fn);
|
||||
expectTypeOf(wrapped).toMatchTypeOf<RateLimited<typeof fn>>();
|
||||
});
|
||||
|
||||
it("attaches __rateLimited as a non-enumerable property", () => {
|
||||
const rateLimit = makeRateLimit();
|
||||
const wrapped = withRateLimit(rateLimit, async (x: number) => x + 1);
|
||||
expect(isRateLimited(wrapped)).toBe(true);
|
||||
expect(Object.keys(wrapped)).not.toContain("__rateLimited");
|
||||
});
|
||||
});
|
||||
|
||||
describe("withRateLimit — factory passthrough", () => {
|
||||
it("forwards arguments and return value unchanged on success", async () => {
|
||||
const rateLimit = makeRateLimit();
|
||||
const fn = async (a: number, b: number) => a + b;
|
||||
const wrapped = withRateLimit(rateLimit, fn);
|
||||
await expect(wrapped(3, 4)).resolves.toBe(7);
|
||||
});
|
||||
|
||||
it("re-throws errors from the inner function", async () => {
|
||||
const rateLimit = makeRateLimit();
|
||||
const err = new Error("inner failure");
|
||||
const fn = async () => {
|
||||
throw err;
|
||||
};
|
||||
const wrapped = withRateLimit(rateLimit, fn);
|
||||
await expect(wrapped()).rejects.toBe(err);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withRateLimit — does not attach other brands", () => {
|
||||
it("does not attach __instrumented or __captured", () => {
|
||||
const rateLimit = makeRateLimit();
|
||||
const wrapped = withRateLimit(rateLimit, async (x: number) => x);
|
||||
expect(isInstrumented(wrapped)).toBe(false);
|
||||
expect(isCaptured(wrapped)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withRateLimit — composition with other wrappers", () => {
|
||||
it("composed result carries __rateLimited when withRateLimit is innermost", () => {
|
||||
const rateLimit = makeRateLimit();
|
||||
const raw = async (x: number) => x * 2;
|
||||
const rateLimited = withRateLimit(rateLimit, raw);
|
||||
|
||||
// Simulate outer wrapper propagating __rateLimited
|
||||
const outer: typeof raw = (...args) => rateLimited(...args);
|
||||
if (
|
||||
(rateLimited as unknown as Record<string, unknown>)["__rateLimited"] ===
|
||||
true
|
||||
) {
|
||||
attachBrand(outer, "__rateLimited");
|
||||
}
|
||||
expect(isRateLimited(outer)).toBe(true);
|
||||
});
|
||||
|
||||
it("original fn reference is not mutated", () => {
|
||||
const rateLimit = makeRateLimit();
|
||||
const fn = async (x: number) => x;
|
||||
const wrapped = withRateLimit(rateLimit, fn);
|
||||
expect(wrapped).not.toBe(fn);
|
||||
expect(isRateLimited(fn)).toBe(false);
|
||||
expect(isRateLimited(wrapped)).toBe(true);
|
||||
});
|
||||
});
|
||||
25
packages/core-shared/src/rate-limit/with-rate-limit.ts
Normal file
25
packages/core-shared/src/rate-limit/with-rate-limit.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { IRateLimit } from "./rate-limit.interface";
|
||||
import type { RateLimited } from "../conformance/brands";
|
||||
import { attachBrand } from "../conformance/brand-runtime";
|
||||
|
||||
/**
|
||||
* Use-case wrapper applied at DI bind time. Attaches the `__rateLimited`
|
||||
* brand so the boot-time assertion can verify rate-limited use cases were
|
||||
* bound through the rate-limit-aware path.
|
||||
*
|
||||
* The forward closure keeps the brand on a fresh function so the original
|
||||
* `fn` reference is not mutated — important when the same factory output is
|
||||
* used elsewhere unwrapped (dev-seed paths, tests).
|
||||
*
|
||||
* Composition order (outermost to innermost):
|
||||
* withSpan → withCapture → withAudit → withAnalytics → withRateLimit → factory(deps)
|
||||
*/
|
||||
export function withRateLimit<Args extends unknown[], R>(
|
||||
rateLimit: IRateLimit,
|
||||
fn: (...args: Args) => Promise<R>,
|
||||
): RateLimited<(...args: Args) => Promise<R>> {
|
||||
void rateLimit; // future: enforce rate limit at runtime per manifest budgets
|
||||
const wrapped: (...args: Args) => Promise<R> = (...args) => fn(...args);
|
||||
attachBrand(wrapped, "__rateLimited");
|
||||
return wrapped as RateLimited<(...args: Args) => Promise<R>>;
|
||||
}
|
||||
Reference in New Issue
Block a user