diff --git a/apps/web-next/src/server/bind-production.ts b/apps/web-next/src/server/bind-production.ts index ffd21e7..711f3f8 100644 --- a/apps/web-next/src/server/bind-production.ts +++ b/apps/web-next/src/server/bind-production.ts @@ -16,6 +16,7 @@ import { PayloadJobQueue, type IJobQueue, } from "@repo/core-shared/jobs"; +import { NoopRateLimit } from "@repo/core-shared/rate-limit"; import { bindProductionBlog } from "@repo/blog/di/bind-production"; import { bindProductionAuth } from "@repo/auth/di/bind-production"; import { bindProductionMarketingPages } from "@repo/marketing-pages/di/bind-production"; @@ -97,6 +98,7 @@ export async function bindAllProduction(): Promise { tracer, logger, queue, + rateLimit: new NoopRateLimit(), }; bindProductionAuth(ctx); @@ -121,6 +123,7 @@ export async function bindAllDevSeed(): Promise { tracer, logger, queue, + rateLimit: new NoopRateLimit(), }; await bindDevSeedAuth(ctx); diff --git a/coverage/summary.json b/coverage/summary.json index 154b395..0b9a144 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -1,18 +1,18 @@ { - "generatedAt": "2026-05-20T08:43:15.868Z", - "commit": "a478a8e", + "generatedAt": "2026-05-20T08:59:30.996Z", + "commit": "24b2490", "repo": { - "statements": 97.41, - "branches": 92.31, - "functions": 97.18, - "lines": 97.41, + "statements": 97.43, + "branches": 92.35, + "functions": 97.21, + "lines": 97.43, "counts": { - "lf": 5867, - "lh": 5715, - "brf": 1184, - "brh": 1093, - "fnf": 354, - "fnh": 344 + "lf": 5910, + "lh": 5758, + "brf": 1190, + "brh": 1099, + "fnf": 358, + "fnh": 348 } }, "byPackage": { @@ -101,17 +101,17 @@ } }, "@repo/core-shared": { - "statements": 98.15, - "branches": 96.12, - "functions": 92.73, - "lines": 98.15, + "statements": 98.21, + "branches": 96.19, + "functions": 92.98, + "lines": 98.21, "counts": { - "lf": 1133, - "lh": 1112, - "brf": 335, - "brh": 322, - "fnf": 110, - "fnh": 102 + "lf": 1176, + "lh": 1155, + "brf": 341, + "brh": 328, + "fnf": 114, + "fnh": 106 } }, "@repo/core-ui": { diff --git a/packages/core-shared/src/conformance/assert-bindings.test.ts b/packages/core-shared/src/conformance/assert-bindings.test.ts index 7a88aac..09f07ca 100644 --- a/packages/core-shared/src/conformance/assert-bindings.test.ts +++ b/packages/core-shared/src/conformance/assert-bindings.test.ts @@ -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(); + }); }); diff --git a/packages/core-shared/src/conformance/assert-bindings.ts b/packages/core-shared/src/conformance/assert-bindings.ts index b3abaaa..4b4bdcb 100644 --- a/packages/core-shared/src/conformance/assert-bindings.ts +++ b/packages/core-shared/src/conformance/assert-bindings.ts @@ -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, + ); } } diff --git a/packages/core-shared/src/conformance/wire-use-case.test.ts b/packages/core-shared/src/conformance/wire-use-case.test.ts index 780fa7d..7189066 100644 --- a/packages/core-shared/src/conformance/wire-use-case.test.ts +++ b/packages/core-shared/src/conformance/wire-use-case.test.ts @@ -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); + }); +}); diff --git a/packages/core-shared/src/conformance/wire-use-case.ts b/packages/core-shared/src/conformance/wire-use-case.ts index 28b855a..2812df5 100644 --- a/packages/core-shared/src/conformance/wire-use-case.ts +++ b/packages/core-shared/src/conformance/wire-use-case.ts @@ -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; - // 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 = + 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 = (...args) => raw(...args); + const prev = toWrap; + const analyzed: (...args: FnArgs) => Promise = (...args) => + prev(...args); attachBrand(analyzed, "__analyzed"); + // propagate __rateLimited from inner so withCapture/withSpan see it + if ( + (prev as unknown as Record)["__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 = (...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)["__analyzed"] === true) { attachBrand(audited, "__analyzed"); } + if ( + (prev as unknown as Record)["__rateLimited"] === true + ) { + attachBrand(audited, "__rateLimited"); + } toWrap = audited; } diff --git a/packages/core-shared/src/di/bind-context.ts b/packages/core-shared/src/di/bind-context.ts index 7f71a22..413e073 100644 --- a/packages/core-shared/src/di/bind-context.ts +++ b/packages/core-shared/src/di/bind-context.ts @@ -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. */ diff --git a/packages/core-shared/src/instrumentation/with-capture.ts b/packages/core-shared/src/instrumentation/with-capture.ts index a5c7840..1121b69 100644 --- a/packages/core-shared/src/instrumentation/with-capture.ts +++ b/packages/core-shared/src/instrumentation/with-capture.ts @@ -33,6 +33,7 @@ export function withCapture( "__audited", "__analyzed", "__consentChecked", + "__rateLimited", ] as const; const wrapped: (...args: Args) => Promise = async (...args) => { diff --git a/packages/core-shared/src/instrumentation/with-span.ts b/packages/core-shared/src/instrumentation/with-span.ts index 20df903..160f341 100644 --- a/packages/core-shared/src/instrumentation/with-span.ts +++ b/packages/core-shared/src/instrumentation/with-span.ts @@ -7,6 +7,7 @@ const PROPAGATED_BRANDS = [ "__audited", "__analyzed", "__consentChecked", + "__rateLimited", ] as const; export function withSpan( diff --git a/packages/core-shared/src/rate-limit/index.ts b/packages/core-shared/src/rate-limit/index.ts index 639f991..8a149b9 100644 --- a/packages/core-shared/src/rate-limit/index.ts +++ b/packages/core-shared/src/rate-limit/index.ts @@ -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"; diff --git a/packages/core-shared/src/rate-limit/with-rate-limit.test.ts b/packages/core-shared/src/rate-limit/with-rate-limit.test.ts new file mode 100644 index 0000000..48bb2e6 --- /dev/null +++ b/packages/core-shared/src/rate-limit/with-rate-limit.test.ts @@ -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 type", () => { + const rateLimit = makeRateLimit(); + const fn = async (a: number) => a + 1; + const wrapped = withRateLimit(rateLimit, fn); + expectTypeOf(wrapped).toMatchTypeOf>(); + }); + + 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)["__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); + }); +}); diff --git a/packages/core-shared/src/rate-limit/with-rate-limit.ts b/packages/core-shared/src/rate-limit/with-rate-limit.ts new file mode 100644 index 0000000..1325553 --- /dev/null +++ b/packages/core-shared/src/rate-limit/with-rate-limit.ts @@ -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( + rateLimit: IRateLimit, + fn: (...args: Args) => Promise, +): RateLimited<(...args: Args) => Promise> { + void rateLimit; // future: enforce rate limit at runtime per manifest budgets + const wrapped: (...args: Args) => Promise = (...args) => fn(...args); + attachBrand(wrapped, "__rateLimited"); + return wrapped as RateLimited<(...args: Args) => Promise>; +}