feat(auth): migrate anonymous consent on signUp when cc_consent cookie present

Adds ConsentFactoryProtocol / ConsentGrantMeta / ConsentProtocol to
core-shared/di/bind-protocols so feature binders can wire per-user
consent without a hard dep on the optional @repo/core-consent package.
BindContext gains an optional consentFactory? field following the same
pattern as bus?, auditLog?, etc.

signUpUseCase gains a 4th optional dep (consentFactory). When present
and the input includes a cookieHeader containing cc_consent=<categories>,
the use case calls consent.grant for each category with
method:"signup-migration" and returns a clearCookie payload (Max-Age:0)
so the anonymous cookie is cleared on the HTTP response.

Tests use RecordingConsent from @repo/core-testing to assert migration
call shape and cookie-clear; no-cookie and no-factory branches are also
covered. All coverage bands hold at 100% for use-cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 21:52:08 +00:00
parent 5151454783
commit 6b66064386
11 changed files with 272 additions and 54 deletions

View File

@@ -1,6 +1,9 @@
import { describe, it, expect } from "vitest";
import { ZodError } from "zod";
import { RecordingEventBus } from "@repo/core-testing/instrumentation";
import {
RecordingEventBus,
RecordingConsent,
} from "@repo/core-testing/instrumentation";
import {
signUpUseCase,
signUpOutputSchema,
@@ -16,7 +19,7 @@ describe("signUpUseCase", () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const useCase = signUpUseCase(users, auth, bus);
const useCase = signUpUseCase(users, auth, bus, undefined);
const result = await useCase({
username: "carol",
@@ -34,7 +37,7 @@ describe("signUpUseCase", () => {
const bus = new RecordingEventBus();
await users.createUser(userFactory.build({ username: "alice" }));
const useCase = signUpUseCase(users, auth, bus);
const useCase = signUpUseCase(users, auth, bus, undefined);
await expect(
useCase({
username: "alice",
@@ -48,7 +51,7 @@ describe("signUpUseCase", () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const useCase = signUpUseCase(users, auth, bus);
const useCase = signUpUseCase(users, auth, bus, undefined);
await useCase({
username: "dave",
@@ -70,7 +73,7 @@ describe("signUpUseCase", () => {
it("works without an event bus (welcome email skipped silently)", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const useCase = signUpUseCase(users, auth, undefined);
const useCase = signUpUseCase(users, auth, undefined, undefined);
const result = await useCase({
username: "frank",
@@ -88,7 +91,7 @@ describe("signUpUseCase", () => {
const bus = new RecordingEventBus();
await users.createUser(userFactory.build({ username: "eve" }));
const useCase = signUpUseCase(users, auth, bus);
const useCase = signUpUseCase(users, auth, bus, undefined);
await expect(
useCase({
username: "eve",
@@ -98,6 +101,108 @@ describe("signUpUseCase", () => {
).rejects.toBeInstanceOf(AuthenticationError);
expect(bus.published).toHaveLength(0);
});
it("migrates anonymous consent when cc_consent cookie is present", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const consent = new RecordingConsent();
const consentFactory = (_userId: string) => Promise.resolve(consent);
const useCase = signUpUseCase(users, auth, bus, consentFactory);
const result = await useCase({
username: "grace",
password: "secret_password",
confirmPassword: "secret_password",
cookieHeader: "cc_consent=necessary,analytics; session=xyz",
});
expect(consent.grants).toHaveLength(2);
expect(consent.grants[0]).toEqual({
category: "necessary",
meta: { method: "signup-migration" },
});
expect(consent.grants[1]).toEqual({
category: "analytics",
meta: { method: "signup-migration" },
});
expect(result.clearCookie).toBeDefined();
expect(result.clearCookie?.name).toBe("cc_consent");
expect(result.clearCookie?.attributes.maxAge).toBe(0);
});
it("does not migrate consent when no cc_consent cookie is present", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const consent = new RecordingConsent();
const consentFactory = (_userId: string) => Promise.resolve(consent);
const useCase = signUpUseCase(users, auth, bus, consentFactory);
const result = await useCase({
username: "henry",
password: "secret_password",
confirmPassword: "secret_password",
cookieHeader: "session=xyz",
});
expect(consent.grants).toHaveLength(0);
expect(result.clearCookie).toBeUndefined();
});
it("does not migrate consent when consentFactory is absent", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const useCase = signUpUseCase(users, auth, bus, undefined);
const result = await useCase({
username: "iris",
password: "secret_password",
confirmPassword: "secret_password",
cookieHeader: "cc_consent=analytics",
});
expect(result.clearCookie).toBeUndefined();
});
it("does not migrate consent when cc_consent cookie has no value", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const consent = new RecordingConsent();
const consentFactory = (_userId: string) => Promise.resolve(consent);
const useCase = signUpUseCase(users, auth, bus, consentFactory);
const result = await useCase({
username: "jake",
password: "secret_password",
confirmPassword: "secret_password",
cookieHeader: "cc_consent=",
});
expect(consent.grants).toHaveLength(0);
expect(result.clearCookie).toBeUndefined();
});
it("parses cookie header with malformed parts (no = sign)", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const bus = new RecordingEventBus();
const consent = new RecordingConsent();
const consentFactory = (_userId: string) => Promise.resolve(consent);
const useCase = signUpUseCase(users, auth, bus, consentFactory);
const result = await useCase({
username: "kate",
password: "secret_password",
confirmPassword: "secret_password",
cookieHeader: "malformedcookie; cc_consent=necessary",
});
expect(consent.grants).toHaveLength(1);
expect(result.clearCookie).toBeDefined();
});
});
describe("signUpUseCase output validation", () => {
@@ -112,7 +217,7 @@ describe("signUpUseCase output validation", () => {
} as unknown as IAuthenticationService;
const bus = new RecordingEventBus();
const useCase = signUpUseCase(users, auth, bus);
const useCase = signUpUseCase(users, auth, bus, undefined);
await expect(
useCase({
username: "carol",

View File

@@ -1,6 +1,9 @@
import { z } from "zod";
import type { EventBusProtocol } from "@repo/core-shared/di";
import type {
EventBusProtocol,
ConsentFactoryProtocol,
} from "@repo/core-shared/di";
import { userSignedUpEvent } from "../../events/user-signed-up.event";
import { AuthenticationError } from "../../entities/errors/auth";
import { cookieSchema } from "../../entities/models/cookie";
@@ -8,12 +11,32 @@ import { sessionSchema } from "../../entities/models/session";
import type { IUsersRepository } from "../repositories/users.repository.interface";
import type { IAuthenticationService } from "../services/authentication.service.interface";
// Cookie name written by the anonymous consent banner (mirrors CONSENT_COOKIE_NAME in @repo/core-consent).
const ANONYMOUS_CONSENT_COOKIE = "cc_consent";
function extractConsentFromCookieHeader(cookieHeader: string): string[] | null {
for (const part of cookieHeader.split(";")) {
const eqIdx = part.indexOf("=");
if (eqIdx === -1) continue;
const name = part.slice(0, eqIdx).trim();
if (name !== ANONYMOUS_CONSENT_COOKIE) continue;
const value = part.slice(eqIdx + 1).trim();
const cats = value
.split(",")
.map((c) => c.trim())
.filter(Boolean);
return cats.length > 0 ? cats : null;
}
return null;
}
// ── Input ────────────────────────────────────────────────────────────────
export const signUpInputSchema = z
.object({
username: z.string().min(3).max(31),
password: z.string().min(6).max(255),
confirmPassword: z.string().min(6).max(255),
cookieHeader: z.string().optional(),
})
.strict()
.refine((d) => d.password === d.confirmPassword, {
@@ -26,6 +49,7 @@ export type SignUpInput = z.infer<typeof signUpInputSchema>;
export const signUpOutputSchema = z.object({
session: sessionSchema,
cookie: cookieSchema,
clearCookie: cookieSchema.optional(),
});
export type SignUpOutput = z.infer<typeof signUpOutputSchema>;
@@ -37,6 +61,7 @@ export const signUpUseCase =
usersRepository: IUsersRepository,
authenticationService: IAuthenticationService,
bus: EventBusProtocol | undefined,
consentFactory: ConsentFactoryProtocol | undefined,
) =>
async (input: SignUpInput): Promise<SignUpOutput> => {
const existingUser = await usersRepository.getUserByUsername(
@@ -71,5 +96,24 @@ export const signUpUseCase =
});
}
return signUpOutputSchema.parse({ session, cookie });
// Migrate anonymous consent when both a cookie header and a consent factory
// are present. consentFactory is optional: absent when core-consent is not wired.
const cookieState = input.cookieHeader
? extractConsentFromCookieHeader(input.cookieHeader)
: null;
let clearCookie: z.infer<typeof cookieSchema> | undefined;
if (cookieState && consentFactory) {
const consent = await consentFactory(newUser.id);
for (const category of cookieState) {
await consent.grant(category, { method: "signup-migration" });
}
clearCookie = {
name: ANONYMOUS_CONSENT_COOKIE,
value: "",
attributes: { maxAge: 0, path: "/" },
};
}
return signUpOutputSchema.parse({ session, cookie, clearCookie });
};

View File

@@ -38,7 +38,15 @@ import type { IAuthenticationService } from "../application/services/authenticat
* populated repo and rebinds the symbol.
*/
export async function bindDevSeedAuth(ctx: BindContext): Promise<void> {
const { tracer, logger, bus, queue, realtime, realtimeRegistry } = ctx;
const {
tracer,
logger,
bus,
queue,
realtime,
realtimeRegistry,
consentFactory,
} = ctx;
// Bind shared instrumentation into feature container
if (authContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
@@ -88,7 +96,7 @@ export async function bindDevSeedAuth(ctx: BindContext): Promise<void> {
container: authContainer,
symbol: AUTH_SYMBOLS.ISignUpUseCase,
factory: signUpUseCase,
deps: [repo, authService, bus],
deps: [repo, authService, bus, consentFactory],
feature: "auth",
layer: "use-case",
name: "signUp",

View File

@@ -30,8 +30,16 @@ export function bindProductionAuth(ctx: BindProductionContext): void {
if (bound) return;
bound = true;
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } =
ctx;
const {
config,
tracer,
logger,
bus,
queue,
realtime,
realtimeRegistry,
consentFactory,
} = ctx;
// Bind shared instrumentation into feature container
if (authContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
@@ -80,7 +88,7 @@ export function bindProductionAuth(ctx: BindProductionContext): void {
container: authContainer,
symbol: AUTH_SYMBOLS.ISignUpUseCase,
factory: signUpUseCase,
deps: [repo, authService, bus],
deps: [repo, authService, bus, consentFactory],
feature: "auth",
layer: "use-case",
name: "signUp",

View File

@@ -39,7 +39,9 @@ export const AuthModule = new ContainerModule((bind: interfaces.Bind) => {
bind<ISignInUseCase>(AUTH_SYMBOLS.ISignInUseCase).toDynamicValue((ctx) =>
signInUseCase(
ctx.container.get<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository),
ctx.container.get<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService),
ctx.container.get<IAuthenticationService>(
AUTH_SYMBOLS.IAuthenticationService,
),
),
);
@@ -49,32 +51,40 @@ export const AuthModule = new ContainerModule((bind: interfaces.Bind) => {
// bus instance when @repo/core-events is scaffolded.
signUpUseCase(
ctx.container.get<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository),
ctx.container.get<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService),
ctx.container.get<IAuthenticationService>(
AUTH_SYMBOLS.IAuthenticationService,
),
undefined,
undefined,
),
);
bind<ISignOutUseCase>(AUTH_SYMBOLS.ISignOutUseCase).toDynamicValue((ctx) =>
signOutUseCase(
ctx.container.get<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService),
ctx.container.get<IAuthenticationService>(
AUTH_SYMBOLS.IAuthenticationService,
),
),
);
bind<ISignInController>(AUTH_SYMBOLS.ISignInController).toDynamicValue((ctx) =>
signInController(
ctx.container.get<ISignInUseCase>(AUTH_SYMBOLS.ISignInUseCase),
),
bind<ISignInController>(AUTH_SYMBOLS.ISignInController).toDynamicValue(
(ctx) =>
signInController(
ctx.container.get<ISignInUseCase>(AUTH_SYMBOLS.ISignInUseCase),
),
);
bind<ISignUpController>(AUTH_SYMBOLS.ISignUpController).toDynamicValue((ctx) =>
signUpController(
ctx.container.get<ISignUpUseCase>(AUTH_SYMBOLS.ISignUpUseCase),
),
bind<ISignUpController>(AUTH_SYMBOLS.ISignUpController).toDynamicValue(
(ctx) =>
signUpController(
ctx.container.get<ISignUpUseCase>(AUTH_SYMBOLS.ISignUpUseCase),
),
);
bind<ISignOutController>(AUTH_SYMBOLS.ISignOutController).toDynamicValue((ctx) =>
signOutController(
ctx.container.get<ISignOutUseCase>(AUTH_SYMBOLS.ISignOutUseCase),
),
bind<ISignOutController>(AUTH_SYMBOLS.ISignOutController).toDynamicValue(
(ctx) =>
signOutController(
ctx.container.get<ISignOutUseCase>(AUTH_SYMBOLS.ISignOutUseCase),
),
);
});

View File

@@ -11,7 +11,12 @@ describe("signUpController", () => {
it("returns a cookie on successful sign-up", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const useCase = signUpUseCase(users, auth, new RecordingEventBus());
const useCase = signUpUseCase(
users,
auth,
new RecordingEventBus(),
undefined,
);
const controller = signUpController(useCase);
const result = await controller({
@@ -26,7 +31,12 @@ describe("signUpController", () => {
it("throws InputParseError when passwords do not match", async () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
const useCase = signUpUseCase(users, auth, new RecordingEventBus());
const useCase = signUpUseCase(
users,
auth,
new RecordingEventBus(),
undefined,
);
const controller = signUpController(useCase);
await expect(
@@ -42,11 +52,20 @@ describe("signUpController", () => {
const users = new MockUsersRepository([]);
const auth = new MockAuthenticationService(users);
await users.createUser(userFactory.build({ username: "alice" }));
const useCase = signUpUseCase(users, auth, new RecordingEventBus());
const useCase = signUpUseCase(
users,
auth,
new RecordingEventBus(),
undefined,
);
const controller = signUpController(useCase);
await expect(
controller({ username: "ab", password: "secret_password", confirmPassword: "secret_password" }),
controller({
username: "ab",
password: "secret_password",
confirmPassword: "secret_password",
}),
).rejects.toBeInstanceOf(InputParseError);
});
});

View File

@@ -19,7 +19,9 @@ describe("auth feature: sign-up → sign-in → sign-out", () => {
const auth = new MockAuthenticationService(users);
const signIn = signInController(signInUseCase(users, auth));
const signUp = signUpController(signUpUseCase(users, auth, new RecordingEventBus()));
const signUp = signUpController(
signUpUseCase(users, auth, new RecordingEventBus(), undefined),
);
const signOut = signOutController(signOutUseCase(auth));
// signUp returns a cookie (presenter shape)

View File

@@ -8,6 +8,7 @@ import type {
MetricsProtocol,
AuditLogProtocol,
AnalyticsProtocol,
ConsentFactoryProtocol,
} from "./bind-protocols";
/** Always-present fields. Feature binders rely on these unconditionally. */
@@ -43,6 +44,7 @@ export type BindContext<
metrics?: Metrics;
auditLog?: Audit;
analytics?: Analytics;
consentFactory?: ConsentFactoryProtocol;
};
/** Production binders also receive the resolved Payload config. */

View File

@@ -80,3 +80,23 @@ export type AnalyticsProtocol = {
attributes?: Record<string, string | number | boolean>,
): void;
};
/**
* Minimal consent protocol surface. `IConsent` (in optional `@repo/core-consent`)
* extends this — typechecks fail if narrowed below. Feature binders that
* receive `ctx.consentFactory` see only this protocol type.
*/
export type ConsentGrantMeta = {
method?: string;
bannerVersion?: string;
policyVersion?: string;
};
export type ConsentProtocol = {
grant(category: string, meta?: ConsentGrantMeta): Promise<void>;
};
/** Factory that creates a per-user consent instance. Mirrors ConsentFactory in `@repo/core-consent`. */
export type ConsentFactoryProtocol = (
userId: string,
) => Promise<ConsentProtocol>;