@@ -29,6 +29,15 @@ const isSafeSourceUrl = (sourceUrl: string): boolean => {
2929 }
3030}
3131
32+ // "https://host/list" and "https://host/list/" must resolve to the same DB
33+ // row — the worker's ensure_mirror() already normalizes to this form before
34+ // cloning (mirror_service.py), so storage must match or the same archive
35+ // ends up mirrored twice under two rows. Kept as a plain function (not a
36+ // zod .transform()/.preprocess()) since either makes the field optional in
37+ // z.infer with the installed zod v4 — reproduced in isolation, unrelated to
38+ // this schema's nesting.
39+ export const canonicalizeSourceUrl = ( sourceUrl : string ) : string => sourceUrl . replace ( / \/ + $ / , '' )
40+
3241export const bodySchema = z . object ( {
3342 lists : z
3443 . array (
@@ -42,14 +51,20 @@ export const bodySchema = z.object({
4251 } ) ,
4352 )
4453 . min ( 1 , 'lists must contain at least one mailing list' )
45- . refine ( ( lists ) => new Set ( lists . map ( ( l ) => l . sourceUrl ) ) . size === lists . length , {
46- message : 'lists contains duplicate sourceUrl entries' ,
47- } ) ,
54+ . refine (
55+ ( lists ) =>
56+ new Set ( lists . map ( ( l ) => canonicalizeSourceUrl ( l . sourceUrl ) ) ) . size === lists . length ,
57+ { message : 'lists contains duplicate sourceUrl entries' } ,
58+ ) ,
4859} )
4960
5061export default async ( req , res ) => {
5162 new PermissionChecker ( req ) . validateHas ( Permissions . values . tenantEdit )
5263 const integrationData = validateOrThrow ( bodySchema , req . body )
64+ integrationData . lists = integrationData . lists . map ( ( l ) => ( {
65+ ...l ,
66+ sourceUrl : canonicalizeSourceUrl ( l . sourceUrl ) ,
67+ } ) )
5368
5469 const payload = await new IntegrationService ( req ) . mailingListConnectOrUpdate ( integrationData )
5570 await req . responseHandler . success ( req , res , payload )
0 commit comments