feat(core-consent): scaffold package with types, IConsent, withConsent brand wrapper
- Add packages/core-consent with ConsentCategory, ConsentState, UserConsentState types and IConsent interface - Add withConsent wrapper attaching __consentChecked brand at bind time; unit tests assert brand attachment and factory passthrough - Add ConsentChecked<F> type to core-shared/conformance/brands.ts and isConsentChecked helper to brand-runtime.ts - Extend FeatureManifest with requiresConsent?: readonly string[] field - Extend assertFeatureConformance to require __consentChecked brand when requiresConsent.length > 0; synthetic fixture tests cover pass/fail cases - Propagate __consentChecked in withSpan PROPAGATED_BRANDS so the outermost binding carries the brand Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -285,6 +285,82 @@ describe("assertFeatureConformance", () => {
|
||||
).toThrow(/Analyzed|__analyzed/);
|
||||
});
|
||||
|
||||
it("throws when feature requiresConsent but binding is missing __consentChecked brand", () => {
|
||||
const container = new Container();
|
||||
const sym = Symbol("test.processData");
|
||||
const ctx = makeCtx();
|
||||
const wrappedNoConsent = withSpan(
|
||||
ctx.tracer,
|
||||
{ name: "test.processData", op: "use-case" },
|
||||
withCapture(
|
||||
ctx.logger,
|
||||
{ feature: "test", layer: "use-case" },
|
||||
async (x: number) => x,
|
||||
),
|
||||
);
|
||||
container.bind(sym).toConstantValue(wrappedNoConsent);
|
||||
|
||||
const manifest = defineFeature({
|
||||
name: "test",
|
||||
requiredCores: [],
|
||||
useCases: {
|
||||
processData: {
|
||||
mutates: false,
|
||||
audits: [],
|
||||
publishes: [],
|
||||
consumes: [],
|
||||
},
|
||||
},
|
||||
realtimeChannels: [],
|
||||
jobs: [],
|
||||
requiresConsent: ["analytics"],
|
||||
} as const);
|
||||
|
||||
expect(() =>
|
||||
assertFeatureConformance(container, manifest, { processData: sym }, ctx),
|
||||
).toThrow(ConformanceError);
|
||||
expect(() =>
|
||||
assertFeatureConformance(container, manifest, { processData: sym }, ctx),
|
||||
).toThrow(/__consentChecked/);
|
||||
});
|
||||
|
||||
it("passes when feature requiresConsent and binding carries __consentChecked brand", () => {
|
||||
const container = new Container();
|
||||
const sym = Symbol("test.processData");
|
||||
const ctx = makeCtx();
|
||||
const base = withSpan(
|
||||
ctx.tracer,
|
||||
{ name: "test.processData", op: "use-case" },
|
||||
withCapture(
|
||||
ctx.logger,
|
||||
{ feature: "test", layer: "use-case" },
|
||||
async (x: number) => x,
|
||||
),
|
||||
);
|
||||
attachBrand(base, "__consentChecked");
|
||||
container.bind(sym).toConstantValue(base);
|
||||
|
||||
const manifest = defineFeature({
|
||||
name: "test",
|
||||
requiredCores: [],
|
||||
useCases: {
|
||||
processData: {
|
||||
mutates: false,
|
||||
audits: [],
|
||||
publishes: [],
|
||||
consumes: [],
|
||||
},
|
||||
},
|
||||
realtimeChannels: [],
|
||||
jobs: [],
|
||||
requiresConsent: ["analytics"],
|
||||
} as const);
|
||||
|
||||
expect(() =>
|
||||
assertFeatureConformance(container, manifest, { processData: sym }, ctx),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("passes when analyticsEvents is empty and __analyzed brand is absent", () => {
|
||||
const container = new Container();
|
||||
const sym = Symbol("test.signIn");
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
isCaptured,
|
||||
isAudited,
|
||||
isAnalyzed,
|
||||
isConsentChecked,
|
||||
} from "./brand-runtime";
|
||||
import { ConformanceError } from "./conformance-error";
|
||||
import type { BindContext } from "../di/bind-context";
|
||||
@@ -70,5 +71,12 @@ export function assertFeatureConformance(
|
||||
);
|
||||
}
|
||||
}
|
||||
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?`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,14 @@
|
||||
* commitment, not a mutable flag.
|
||||
*/
|
||||
|
||||
import type { Analyzed } from "./brands";
|
||||
import type { Analyzed, ConsentChecked } from "./brands";
|
||||
|
||||
type Brand = "__instrumented" | "__captured" | "__audited" | "__analyzed";
|
||||
type Brand =
|
||||
| "__instrumented"
|
||||
| "__captured"
|
||||
| "__audited"
|
||||
| "__analyzed"
|
||||
| "__consentChecked";
|
||||
|
||||
/**
|
||||
* Attaches the brand as a non-enumerable property on the given function.
|
||||
@@ -57,3 +62,9 @@ export function isAudited(fn: unknown): boolean {
|
||||
export function isAnalyzed<F extends object>(fn: unknown): fn is Analyzed<F> {
|
||||
return hasBrand(fn, "__analyzed");
|
||||
}
|
||||
|
||||
export function isConsentChecked<F extends object>(
|
||||
fn: unknown,
|
||||
): fn is ConsentChecked<F> {
|
||||
return hasBrand(fn, "__consentChecked");
|
||||
}
|
||||
|
||||
@@ -10,3 +10,4 @@
|
||||
export type Instrumented<F> = F & { readonly __instrumented: true };
|
||||
export type Captured<F> = F & { readonly __captured: true };
|
||||
export type Analyzed<F> = F & { readonly __analyzed: true };
|
||||
export type ConsentChecked<F> = F & { readonly __consentChecked: true };
|
||||
|
||||
@@ -29,6 +29,14 @@ export type FeatureManifest = {
|
||||
readonly realtimeChannels: readonly string[];
|
||||
readonly jobs: readonly string[];
|
||||
readonly coverage?: CoverageManifest;
|
||||
/**
|
||||
* Consent categories this feature's use cases require before processing
|
||||
* personal data. When non-empty, `assertFeatureConformance` requires every
|
||||
* bound use case to carry the `__consentChecked` brand from `withConsent`.
|
||||
* Defaults to `[]` — existing features with no consent requirements omit
|
||||
* or declare an empty array.
|
||||
*/
|
||||
readonly requiresConsent?: readonly string[];
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export type { Instrumented, Captured, Analyzed } from "./brands";
|
||||
export type {
|
||||
Instrumented,
|
||||
Captured,
|
||||
Analyzed,
|
||||
ConsentChecked,
|
||||
} from "./brands";
|
||||
export type { FeatureManifest, UseCaseManifest } from "./define-feature";
|
||||
export { defineFeature } from "./define-feature";
|
||||
export type {
|
||||
@@ -23,6 +28,7 @@ export {
|
||||
isCaptured,
|
||||
isAudited,
|
||||
isAnalyzed,
|
||||
isConsentChecked,
|
||||
} from "./brand-runtime";
|
||||
export { ConformanceError } from "./conformance-error";
|
||||
export { assertFeatureConformance } from "./assert-bindings";
|
||||
|
||||
@@ -2,7 +2,12 @@ import type { ITracer, SpanOpts } from "./tracer.interface";
|
||||
import type { Instrumented } from "../conformance/brands";
|
||||
import { attachBrand } from "../conformance/brand-runtime";
|
||||
|
||||
const PROPAGATED_BRANDS = ["__captured", "__audited", "__analyzed"] as const;
|
||||
const PROPAGATED_BRANDS = [
|
||||
"__captured",
|
||||
"__audited",
|
||||
"__analyzed",
|
||||
"__consentChecked",
|
||||
] as const;
|
||||
|
||||
export function withSpan<Args extends unknown[], R, Extra extends object>(
|
||||
tracer: ITracer,
|
||||
|
||||
Reference in New Issue
Block a user