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 { 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 });
};