diff --git a/coverage/summary.json b/coverage/summary.json index 8d9640e..1f7d879 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -1,18 +1,18 @@ { - "generatedAt": "2026-05-19T21:51:40.371Z", - "commit": "5151454", + "generatedAt": "2026-05-20T08:21:18.294Z", + "commit": "a633561", "repo": { "statements": 97.38, - "branches": 92.19, - "functions": 97.1, + "branches": 92.2, + "functions": 97.11, "lines": 97.38, "counts": { - "lf": 5801, - "lh": 5649, - "brf": 1165, - "brh": 1074, - "fnf": 345, - "fnh": 335 + "lf": 5807, + "lh": 5655, + "brf": 1166, + "brh": 1075, + "fnf": 346, + "fnh": 336 } }, "byPackage": { @@ -101,17 +101,17 @@ } }, "@repo/core-shared": { - "statements": 98.03, - "branches": 95.89, - "functions": 92.08, - "lines": 98.03, + "statements": 98.04, + "branches": 95.9, + "functions": 92.16, + "lines": 98.04, "counts": { - "lf": 1067, - "lh": 1046, - "brf": 316, - "brh": 303, - "fnf": 101, - "fnh": 93 + "lf": 1073, + "lh": 1052, + "brf": 317, + "brh": 304, + "fnf": 102, + "fnh": 94 } }, "@repo/core-ui": { diff --git a/packages/core-shared/package.json b/packages/core-shared/package.json index 14e5649..7630c7f 100644 --- a/packages/core-shared/package.json +++ b/packages/core-shared/package.json @@ -6,6 +6,7 @@ "exports": { ".": "./src/index.ts", "./audit": "./src/audit/index.ts", + "./rate-limit": "./src/rate-limit/index.ts", "./conformance": "./src/conformance/index.ts", "./conformance/coverage": "./src/conformance/coverage.ts", "./di": "./src/di/index.ts", diff --git a/packages/core-shared/src/conformance/brand-runtime.test.ts b/packages/core-shared/src/conformance/brand-runtime.test.ts index 85bcb41..3c5e2fe 100644 --- a/packages/core-shared/src/conformance/brand-runtime.test.ts +++ b/packages/core-shared/src/conformance/brand-runtime.test.ts @@ -5,6 +5,7 @@ import { isCaptured, isAudited, isAnalyzed, + isRateLimited, } from "@/conformance/brand-runtime"; describe("brand-runtime", () => { @@ -23,6 +24,7 @@ describe("brand-runtime", () => { expect(isCaptured(fn)).toBe(false); expect(isAudited(fn)).toBe(false); expect(isAnalyzed(fn)).toBe(false); + expect(isRateLimited(fn)).toBe(false); }); it("predicates discriminate between brands", () => { @@ -45,6 +47,11 @@ describe("brand-runtime", () => { const analyzed = () => {}; attachBrand(analyzed, "__analyzed"); expect(isAnalyzed(analyzed)).toBe(true); + + const rateLimited = () => {}; + attachBrand(rateLimited, "__rateLimited"); + expect(isRateLimited(rateLimited)).toBe(true); + expect(isInstrumented(rateLimited)).toBe(false); }); it("composing brands stacks them on the same function", () => { @@ -53,10 +60,12 @@ describe("brand-runtime", () => { attachBrand(fn, "__captured"); attachBrand(fn, "__audited"); attachBrand(fn, "__analyzed"); + attachBrand(fn, "__rateLimited"); expect(isInstrumented(fn)).toBe(true); expect(isCaptured(fn)).toBe(true); expect(isAudited(fn)).toBe(true); expect(isAnalyzed(fn)).toBe(true); + expect(isRateLimited(fn)).toBe(true); }); it("attached brands are non-writable and non-configurable", () => { diff --git a/packages/core-shared/src/conformance/brand-runtime.ts b/packages/core-shared/src/conformance/brand-runtime.ts index c51998a..bb2b3e5 100644 --- a/packages/core-shared/src/conformance/brand-runtime.ts +++ b/packages/core-shared/src/conformance/brand-runtime.ts @@ -13,14 +13,15 @@ * commitment, not a mutable flag. */ -import type { Analyzed, ConsentChecked } from "./brands"; +import type { Analyzed, ConsentChecked, RateLimited } from "./brands"; type Brand = | "__instrumented" | "__captured" | "__audited" | "__analyzed" - | "__consentChecked"; + | "__consentChecked" + | "__rateLimited"; /** * Attaches the brand as a non-enumerable property on the given function. @@ -68,3 +69,9 @@ export function isConsentChecked( ): fn is ConsentChecked { return hasBrand(fn, "__consentChecked"); } + +export function isRateLimited( + fn: unknown, +): fn is RateLimited { + return hasBrand(fn, "__rateLimited"); +} diff --git a/packages/core-shared/src/conformance/brands.test.ts b/packages/core-shared/src/conformance/brands.test.ts index 3e09bee..43a33f7 100644 --- a/packages/core-shared/src/conformance/brands.test.ts +++ b/packages/core-shared/src/conformance/brands.test.ts @@ -1,5 +1,5 @@ import { describe, it, expectTypeOf } from "vitest"; -import type { Instrumented, Captured } from "@/conformance/brands"; +import type { Instrumented, Captured, RateLimited } from "@/conformance/brands"; describe("brand types", () => { it("Instrumented is structurally F plus a phantom flag", () => { @@ -16,6 +16,12 @@ describe("brand types", () => { expectTypeOf["__captured"]>().toEqualTypeOf(); }); + it("RateLimited is structurally F plus a phantom flag", () => { + type Fn = (n: number) => Promise; + expectTypeOf>().toBeCallableWith(1); + expectTypeOf["__rateLimited"]>().toEqualTypeOf(); + }); + it("brands compose without conflict", () => { type Fn = (n: number) => Promise; type Both = Instrumented & Captured; diff --git a/packages/core-shared/src/conformance/brands.ts b/packages/core-shared/src/conformance/brands.ts index ed8190a..76b5f8b 100644 --- a/packages/core-shared/src/conformance/brands.ts +++ b/packages/core-shared/src/conformance/brands.ts @@ -11,3 +11,4 @@ export type Instrumented = F & { readonly __instrumented: true }; export type Captured = F & { readonly __captured: true }; export type Analyzed = F & { readonly __analyzed: true }; export type ConsentChecked = F & { readonly __consentChecked: true }; +export type RateLimited = F & { readonly __rateLimited: true }; diff --git a/packages/core-shared/src/conformance/define-feature.test.ts b/packages/core-shared/src/conformance/define-feature.test.ts index 0ea084a..1a97a03 100644 --- a/packages/core-shared/src/conformance/define-feature.test.ts +++ b/packages/core-shared/src/conformance/define-feature.test.ts @@ -2,7 +2,9 @@ import { describe, it, expect, expectTypeOf } from "vitest"; import { defineFeature, type FeatureManifest, + type UseCaseManifest, } from "@/conformance/define-feature"; +import type { RateLimitBudget } from "@/rate-limit/rate-limit.interface"; import { DEFAULT_COVERAGE_BANDS, DEFAULT_MUTATION_SCORE, @@ -58,6 +60,41 @@ describe("defineFeature", () => { expectTypeOf(manifest).toMatchTypeOf(); }); + it("accepts an optional rateLimit array on a use case", () => { + const manifest = defineFeature({ + name: "auth", + requiredCores: [], + useCases: { + signIn: { + mutates: false, + audits: [], + publishes: [], + consumes: [], + rateLimit: [{ name: "login", window: "1m", budget: 5 }], + }, + }, + realtimeChannels: [], + jobs: [], + } as const); + + expectTypeOf(manifest).toMatchTypeOf(); + expectTypeOf(manifest.useCases.signIn.rateLimit).toMatchTypeOf< + readonly RateLimitBudget[] | undefined + >(); + }); + + it("use case without rateLimit satisfies UseCaseManifest", () => { + const uc: UseCaseManifest = { + mutates: false, + audits: [], + publishes: [], + consumes: [], + }; + expectTypeOf(uc.rateLimit).toEqualTypeOf< + readonly RateLimitBudget[] | undefined + >(); + }); + it("accepts an optional coverage section", () => { const manifest = defineFeature({ name: "auth", diff --git a/packages/core-shared/src/conformance/define-feature.ts b/packages/core-shared/src/conformance/define-feature.ts index 04f330c..d3fcb06 100644 --- a/packages/core-shared/src/conformance/define-feature.ts +++ b/packages/core-shared/src/conformance/define-feature.ts @@ -1,4 +1,5 @@ import type { CoverageManifest } from "./coverage"; +import type { RateLimitBudget } from "../rate-limit/rate-limit.interface"; /** * Per-use-case manifest entry. Declares what the use case does at the contract @@ -12,6 +13,7 @@ export type UseCaseManifest = { readonly publishes: readonly string[]; readonly consumes: readonly string[]; readonly analyticsEvents?: readonly string[]; + readonly rateLimit?: readonly RateLimitBudget[]; }; /** diff --git a/packages/core-shared/src/conformance/index.ts b/packages/core-shared/src/conformance/index.ts index e68672e..3f38af3 100644 --- a/packages/core-shared/src/conformance/index.ts +++ b/packages/core-shared/src/conformance/index.ts @@ -3,6 +3,7 @@ export type { Captured, Analyzed, ConsentChecked, + RateLimited, } from "./brands"; export type { FeatureManifest, UseCaseManifest } from "./define-feature"; export { defineFeature } from "./define-feature"; @@ -29,6 +30,7 @@ export { isAudited, isAnalyzed, isConsentChecked, + isRateLimited, } from "./brand-runtime"; export { ConformanceError } from "./conformance-error"; export { assertFeatureConformance } from "./assert-bindings"; diff --git a/packages/core-shared/src/rate-limit/index.ts b/packages/core-shared/src/rate-limit/index.ts new file mode 100644 index 0000000..290e9d9 --- /dev/null +++ b/packages/core-shared/src/rate-limit/index.ts @@ -0,0 +1,5 @@ +export type { + IRateLimit, + RateLimitBudget, + RateLimitDecision, +} from "./rate-limit.interface"; diff --git a/packages/core-shared/src/rate-limit/rate-limit.interface.test.ts b/packages/core-shared/src/rate-limit/rate-limit.interface.test.ts new file mode 100644 index 0000000..8d5b9be --- /dev/null +++ b/packages/core-shared/src/rate-limit/rate-limit.interface.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expectTypeOf } from "vitest"; +import type { + IRateLimit, + RateLimitBudget, + RateLimitDecision, +} from "@/rate-limit/rate-limit.interface"; + +describe("rate-limit types", () => { + it("RateLimitBudget has the expected shape", () => { + expectTypeOf().toMatchTypeOf<{ + name: string; + window: string; + budget: number; + }>(); + }); + + it("RateLimitDecision has the expected shape", () => { + expectTypeOf().toMatchTypeOf<{ + allowed: boolean; + remaining: number; + resetAt: Date; + }>(); + }); + + it("IRateLimit.consume returns Promise", () => { + expectTypeOf< + IRateLimit["consume"] + >().returns.resolves.toEqualTypeOf(); + }); + + it("IRateLimit.consume accepts optional weight", () => { + type ConsumeParams = Parameters; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it("IRateLimit.reset returns Promise", () => { + expectTypeOf().returns.resolves.toEqualTypeOf(); + }); +}); diff --git a/packages/core-shared/src/rate-limit/rate-limit.interface.ts b/packages/core-shared/src/rate-limit/rate-limit.interface.ts new file mode 100644 index 0000000..1d36d4e --- /dev/null +++ b/packages/core-shared/src/rate-limit/rate-limit.interface.ts @@ -0,0 +1,20 @@ +export type RateLimitBudget = { + name: string; + window: string; + budget: number; +}; + +export type RateLimitDecision = { + allowed: boolean; + remaining: number; + resetAt: Date; +}; + +export interface IRateLimit { + consume( + budgetName: string, + key: string, + weight?: number, + ): Promise; + reset(budgetName: string, key: string): Promise; +}