Files
agentic-dev/packages/auth/src/application/use-cases/sign-up.use-case.ts
Danijel Martinek 6b66064386 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>
2026-05-19 21:52:08 +00:00

120 lines
4.5 KiB
TypeScript

import { z } from "zod";
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";
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, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
export type SignUpInput = z.infer<typeof signUpInputSchema>;
// ── Output ───────────────────────────────────────────────────────────────
export const signUpOutputSchema = z.object({
session: sessionSchema,
cookie: cookieSchema,
clearCookie: cookieSchema.optional(),
});
export type SignUpOutput = z.infer<typeof signUpOutputSchema>;
// ── Use case ─────────────────────────────────────────────────────────────
export type ISignUpUseCase = ReturnType<typeof signUpUseCase>;
export const signUpUseCase =
(
usersRepository: IUsersRepository,
authenticationService: IAuthenticationService,
bus: EventBusProtocol | undefined,
consentFactory: ConsentFactoryProtocol | undefined,
) =>
async (input: SignUpInput): Promise<SignUpOutput> => {
const existingUser = await usersRepository.getUserByUsername(
input.username,
);
if (existingUser) {
throw new AuthenticationError("Username taken");
}
const passwordHash = await authenticationService.hashPassword(
input.password,
);
const userId = authenticationService.generateUserId();
const newUser = await usersRepository.createUser({
id: userId,
username: input.username,
passwordHash,
});
const { cookie, session } =
await authenticationService.createSession(newUser);
// Auth is username-based — synthesize a deterministic email so the event
// payload validates against userSignedUpEventSchema.email().
// bus is optional: absent when core-events is not wired.
if (bus) {
await bus.publish(userSignedUpEvent, {
userId: newUser.id,
email: `${newUser.username}@example.local`,
signedUpAt: new Date().toISOString(),
});
}
// 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 });
};