diff --git a/packages/core-analytics/src/analytics.interface.ts b/packages/core-analytics/src/analytics.interface.ts index 5ecc7fe..4ba0e70 100644 --- a/packages/core-analytics/src/analytics.interface.ts +++ b/packages/core-analytics/src/analytics.interface.ts @@ -1,10 +1,18 @@ +import type { AnalyticsProtocol } from "@repo/core-shared/di/bind-protocols"; + export type AnalyticsAttributeValue = string | number | boolean; export type AnalyticsUser = { id: string; }; -export interface IAnalytics { +/** + * Product-analytics sink. Extends `AnalyticsProtocol` from + * `@repo/core-shared/di/bind-protocols` — the surface feature binders see via + * `ctx.analytics` — so narrowing the protocol fails typecheck here instead of + * silently drifting apart. + */ +export interface IAnalytics extends AnalyticsProtocol { track( event: string, attributes?: Record, diff --git a/packages/core-shared/src/conformance/production-use-case.test.ts b/packages/core-shared/src/conformance/production-use-case.test.ts index cd482b6..6115786 100644 --- a/packages/core-shared/src/conformance/production-use-case.test.ts +++ b/packages/core-shared/src/conformance/production-use-case.test.ts @@ -11,12 +11,50 @@ describe("ProductionUseCase", () => { consumes: readonly []; }; type Slot = ProductionUseCase<{ x: number }, { y: string }, Manifest>; - type Wrapped = Instrumented<(input: { x: number }) => Promise<{ y: string }>> & + type Wrapped = Instrumented< + (input: { x: number }) => Promise<{ y: string }> + > & Captured<(input: { x: number }) => Promise<{ y: string }>>; expectTypeOf().toMatchTypeOf(); }); + it("requires Analyzed when the manifest declares analyticsEvents", () => { + type Manifest = { + mutates: false; + audits: readonly []; + publishes: readonly []; + consumes: readonly []; + analyticsEvents: readonly ["auth.signed_in"]; + }; + type Fn = (input: { x: number }) => Promise<{ y: string }>; + type Slot = ProductionUseCase<{ x: number }, { y: string }, Manifest>; + type WithoutAnalyzed = Instrumented & Captured; + type WithAnalyzed = WithoutAnalyzed & { readonly __analyzed: true }; + + expectTypeOf().toMatchTypeOf(); + expectTypeOf().not.toMatchTypeOf(); + }); + + it("requires RateLimited when the manifest declares rateLimit budgets", () => { + type Manifest = { + mutates: true; + audits: readonly []; + publishes: readonly []; + consumes: readonly []; + rateLimit: readonly [{ name: "ip"; window: "1m"; budget: 5 }]; + }; + type Fn = (input: { x: number }) => Promise<{ y: string }>; + type Slot = ProductionUseCase<{ x: number }, { y: string }, Manifest>; + type WithoutRateLimited = Instrumented & Captured; + type WithRateLimited = WithoutRateLimited & { + readonly __rateLimited: true; + }; + + expectTypeOf().toMatchTypeOf(); + expectTypeOf().not.toMatchTypeOf(); + }); + it("a plain factory is NOT assignable to the slot", () => { type Manifest = { mutates: false; diff --git a/packages/core-shared/src/conformance/production-use-case.ts b/packages/core-shared/src/conformance/production-use-case.ts index 26079f5..238bd8e 100644 --- a/packages/core-shared/src/conformance/production-use-case.ts +++ b/packages/core-shared/src/conformance/production-use-case.ts @@ -1,10 +1,12 @@ import type { UseCaseManifest } from "./define-feature"; -import type { Instrumented, Captured } from "./brands"; +import type { Instrumented, Captured, Analyzed, RateLimited } from "./brands"; /** * Type-level binding slot for production use cases. Derived from the manifest * entry: every binding must be Instrumented + Captured; mutating use cases - * that declare audits additionally must be Audited. The Audited brand lives + * that declare audits additionally must be Audited; use cases that declare + * non-empty `analyticsEvents` must be Analyzed; use cases that declare + * non-empty `rateLimit` must be RateLimited. The Audited brand lives * in `@repo/core-audit` because the wrap helper that attaches it depends on * `IAuditLog` — feature packages import the merged slot type implicitly * by typing their bindings as `ProductionUseCase`. @@ -13,12 +15,24 @@ import type { Instrumented, Captured } from "./brands"; * without depending on core-audit. When `mutates: true` AND `audits` is * non-empty, the slot demands a marker type with a `__audited` flag; the * concrete `Audited` from core-audit satisfies it. + * + * `requiresConsent` is feature-scoped (a `FeatureManifest` field, not a + * per-use-case one), so the `__consentChecked` brand cannot be derived from + * the use-case entry here — it is enforced at boot by + * `assertFeatureConformance` instead. */ -export type ProductionUseCase = - & Instrumented<(input: I) => Promise> - & Captured<(input: I) => Promise> - & (M["mutates"] extends true - ? M["audits"]["length"] extends 0 - ? unknown - : { readonly __audited: true } - : unknown); +export type ProductionUseCase = Instrumented< + (input: I) => Promise +> & + Captured<(input: I) => Promise> & + (M["mutates"] extends true + ? M["audits"]["length"] extends 0 + ? unknown + : { readonly __audited: true } + : unknown) & + (M["analyticsEvents"] extends readonly [string, ...string[]] + ? Analyzed<(input: I) => Promise> + : unknown) & + (M["rateLimit"] extends readonly [unknown, ...unknown[]] + ? RateLimited<(input: I) => Promise> + : unknown);