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>
120 lines
4.5 KiB
TypeScript
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 });
|
|
};
|