diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d41b393 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,36 @@ +name: Build + +on: + push: + branches: + - main + +jobs: + build: + name: Build and analyze + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: 25 + distribution: "zulu" # Alternative distribution options are available. + - name: Cache SonarQube packages + uses: actions/cache@v4 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + - name: Cache Maven packages + uses: actions/cache@v4 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-m2 + - name: Build and analyze + run: mvn -B verify + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99886da --- /dev/null +++ b/.gitignore @@ -0,0 +1,79 @@ +# ========================================== +# Credentials, Secrets, and Environment Files +# ========================================== +.env +.env.* +!.env.example +*.pem +*.key +*.cert +secrets/ +credentials.json + +# ========================================== +# Java / Spring Boot (Maven/Gradle) +# ========================================== +target/ +**/target/ +build/ +**/build/ +*.class +*.jar +*.war +*.ear +*.iml +*.lst + +# ========================================== +# Frontend (Node.js / Next.js) +# ========================================== +node_modules/ +**/node_modules/ +.next/ +**/.next/ +out/ +**/out/ +dist/ +**/dist/ +.vercel/ +*.tsbuildinfo +next-env.d.ts +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# ========================================== +# Docker & Local Database Volumes +# ========================================== +keycloak-data/ +postgres-data/ +db-data/ + +# ========================================== +# IDEs, Editors, and OS files +# ========================================== +.idea/ +.vscode/ +*.swp +*.swo +*.iws +*.ipr +.classpath +.project +.settings/ +.nb-gradle/ +nbproject/ +nbbuild/ +.sts4-cache/ +.DS_Store +Thumbs.db + +# ========================================== +# SonarQube / JaCoCo +# ========================================== +.sonar/ +**/.sonar/ +**/jacoco.exec + +.agent/ \ No newline at end of file diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..89ed517 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,4 @@ +target/ +.idea/ +*.iml +.env diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..1ddf29c --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,13 @@ +FROM maven:3.9-eclipse-temurin-25-alpine AS build +WORKDIR /app +COPY pom.xml . +RUN mvn dependency:go-offline -B + +COPY src src +RUN mvn clean package -DskipTests + +FROM eclipse-temurin:25-jre-alpine +WORKDIR /app +COPY --from=build /app/target/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "--enable-preview", "-jar", "app.jar"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..aced197 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,505 @@ +do uzupelniania a poki co zeby tu nie było nudno tranksrypcja odcinka "wrong place wrong time": + +(At the Destiny's Bounty, the ninja are training Lloyd.) + +Wu: Your new body is a fighting instrument. Listen to it. + +(The session begins with everyone attacking Lloyd and him dodging each one, all while his eyes are closed. But when he dodges Zane's shurikens, they end up hitting the watermelon Nya is carrying in. She shoots a glare at Zane, who gasps.) + +Nya: Since he's grown, he's learning faster, Sensei. + +Wu: But will it be enough to challenge Lord Garmadon? Only time will tell. (Kai draws his sword and although Lloyd blocks it, he doesn't stop the attack and falls.) + +Kai: Hahaha, you're getting pretty good, but that move you fell for was basic 101. + +Lloyd: Yeah, if I had focused more when I was little, I would've seen that coming. + +Wu: We cannot change the past, but we can improve for the future. Again, and this time, do not hold back. + +(The Falcon squawks as it returns to the Bounty.) + +Zane: The Falcon has returned with troubling news. What did you see, my mechanical-feathered friend? + +Nya: The Lost City of Ouroboros! + +Kai: Garmadon has returned to the Serpentine home base, but why? + +Cole: Looks like they're preparing for a final battle. + +Wu: Nya, change course. We'll head straight for them. + +(They suit up and get there at night.) + +Garmadon: More firepower! When we attack the ninja, they won't see it coming. Our forces will be so strong, Ninjago will be mine! (Fang-Suei uses a blowtorch on his Mega Weapon, making him pull it away.) Aargh! + +Wu: Ninja, go! + +Garmadon: Oh, brother! + +Wu: It's time we finished this. + +Ninja: Ugh! + +Garmadon: Bring it on, fools! + +(Lloyd jumps in and freezes his Mega Weapon.) + +Garmadon: Aargh! Lloyd? Is that you? + +Lloyd: Yeah. I've grown a little since the last time you saw me, Dad. + +Garmadon: Stop him! (He runs away into a chamber and breaks the ice.) Blast these ninja! If only they weren't around. If only they never existed! Ugh! Yes. Yes, that's it! I wish I could go back in time, and make it so the ninja were never formed in the first place! (The weapon makes a time portal.) Now, to finish the ninja once and for all, so that my son never becomes the Green Ninja! (He jumps in, but doesn't see the ninja entering the chamber.) + +Cole: He used the Mega Weapon again! + +Jay: Oh, swell. Every time he uses that thing, something really bad happens. Now what? + +(The ground starts to rumble and turn into sand.) + +Kai: What's going on? + +Jay: Is the city sinking? + +Zane: Worse. The city's returning back into the desert, as if it had never been found in the first place. + +Kai: Uh, what? What does that mean? + +Nya: What's happening? + +Zane: Garmadon's gone back in time to make it so Lloyd never turns into the Green Ninja. + +Lloyd: I can't...I can't feel my hands. + +Kai: Uh. Ugh. We have to go in! + +Wu: If he changes anything, he changes everything! Protect the future, ninja! + +(The ninja jump into the portal before they screamed.) + +(The ninja land in a mud puddle in Ignacia Village.) + +Ninja: Whoa! + +Kai: Ugh. + +Jay: Remind me to pack a helmet next time we time travel. + +Cole: Where are we? + +Zane: You mean when are we? + +(Kai looks at the familiar shop.) + +Kai: Four Weapons. Ha! My parents' shop. Nya and I used to work there. (Realizes) Hey, wait a minute, this is the day Garmadon ordered the Skeletons to take my sister. We need to warn them! + +Cole: Whoa, whoa! You heard what Sensei said: we change anything, we change everything. + +Jay: Yeah, I was confused by that too. + +Zane: What he means is, our world is the result of past events that have already happened. If for whatever reason events in our past fail to happen, then the future could radically change. + +Jay: Uh, so what you're saying is we gotta stop Garmadon from changing anything? But of all the times, why did we land here? + +Kai: 'Cause this is where it all started. The day I met Sensei Wu for the first time and I began my training as a ninja. Somehow, Garmadon is gonna try to prevent that, but how? + +Cole: (Offscreen; Sighs) If only Sensei were here to guide us. + +Jay: (Sees Past Wu walking) Oh look, there he is! Maybe he can! + +Cole: We gotta tell him what Garmadon has done. + +Zane: (Offscreen) But we can't let the old Kai and Nya see that we talked to him, or else it will affect the future. + +Kai: Hmm. I know exactly what to do. (As they climb the stairs, Past Kai is on the final words of his first conversation with Wu.) + +Past Kai: And the shop is called "Four Weapons," not "For Browsing." Either buy something or go pedal your insults somewhere else. + +Past Wu: Ha! Too bad. Thought I'd find something special here. + +Kai: (covers Past Wu's mouth) Sensei, we need your help! + +Jay: Whoa, whoa, whoa. Wait a minute. If we're trying to stop Garmadon from changing anything, wouldn't talking to Sensei kind of be like altering the past, thus changing our future? + +Zane: That would be correct. I believe a big "uh-oh" is in order. + +Past Wu: Uh? + +Kai: Well, we can't undo what we've already done. Sensei, we really need your help. + +Past Wu: All four of you…. You...you... + +Cole: Traveled back in time to warn you of impending doom? + +Kai: Yeah, but we don't have time to explain. Can you help us? + +Past Wu: I don't understand, but I can try. + +Kai: Okay. Any moment now, the Skeletons are going to attack and we think Garmadon may try to hurt me—I mean the past me. + +Past Wu: Garmadon? + +Kai: We have to do everything in our power to protect my past self. + +(The Skulkin arrive, but Kai stopped Past Wu from interfering.) + +Past Nuckal: Oh, oh! Let me go first! Please, oh please? I'm dying to go down there. + +Past Kruncha: You nitwit, you're already dead. + +Garmadon: (Putting on a Skeleton mask. Simultaneously) Why did I ever trust these numbskulls? + +Past Samukai: Attack! Boo! + +(The Ignacia citizens run away.) + +Past Nya: What are they? + +Past Kai: I don't know. Stay here. (Grunts as he fights a few Skeletons.) + +Kai: Well, can we help or not? + +Past Wu: Don't look at me. I'm wise, but not that wise. + +Zane: Uh, technically, since the past has already been altered, I don't see why we can't fight a few Skeletons. Just as long as your former self never sees you. + +Cole: Ha, you made my day, Zane. + +Jay: I miss beating these guys up. (Grunts) Ninja, go! Haha. + +Kai: Watch for when Samukai throws his daggers. And don't forget to save me. + +Past Wu: Huh? + +Kai: Uh, it'll make sense when it happens. + +Past Kai: (Sees Nya join him) I thought I told you to stay back. + +Past Nya: And what? Let you have all the fun? + +Past Kai: Oops. (Samukai uses his four daggers against Kai's one.) Ugh! + +Kai: Sensei! + +Past Wu: I'm on it. Ninja, go! (He uses Spinjitzu on Samukai.) + +Past Samukai: Sensei Wu, your Spinjitzu looks rusty. + +Past Wu: Nothing like bone to sharpen its edge, Samukai. + +Past Samukai: Hehehe, aargh! (He throws his daggers at a nearby water tower, making it collapse toward Past Kai's direction as Kai watches.) + +Past Kai: Oh, no. + +Past Wu: Ninja, go! (He saves Past Kai.) + +Past Samukai: Lord Garmadon says take the girl. + +(Garmadon knocks out Past Kruncha and misses on purpose.) + +Past Kruncha: Uh, aargh. + +Past Samukai: Fools, you missed! Retreat! + +Past Nuckal: Huh? (He sees two Kais.) + +Past Kruncha: What is it? + +Past Nuckal: Uh, nothing. + +(In the present, Wu is viewing Ninjago City covered in smoke. In a picture of the ninja, Lloyd disappears.) + +Wu: Oh, my goodness. + +(In the past, Past Kai and Nya embrace.) + +Kai: Oh, this isn't good. + +Past Wu: Why? We saved you and your sister. + +Kai: No, the Skeletons were supposed to get away with Nya. Ah. The only reason you convinced me to become a ninja in the first place was to get her back. + +Past Wu: Oh, I see. + +Zane: Sensei, no matter what, you need to convince Kai to go with you. + +Jay: Why? + +Zane: Because if Kai doesn't become a ninja, the world as we know it won't exist. The four of us will never unite. We'll never train Lloyd to become the Green Ninja. And Nya will never fall head-over-heels for Jay. + +Jay: (Gasps) NO MATTER WHAT, YOU NEED TO CONVINCE HIM TO GO WITH YOU!! + +Past Wu: Uh… (He signals them to hide.) + +Past Kai: Thank you for your help. If it wasn't for your Twistitzu or your Tornadzu, my sister might have been taken. + +Past Wu: Spinjitzu. Uh, come, we must train. You must become a ninja. + +Past Kai: Hahaha. Sorry, but I got a blacksmith shop to help get back on its feet, so I— + +Past Wu: No. You must come! It is very important that you become a ninja. Because, uh...we must harness the fire within you! + +Past Nya: Hold on, Kai. Maybe there's a reason this man came into our lives. Father always said the world works in mysterious ways. I think it sounds kind of exciting. + +Past Kai: Ha. Okay, if you wanna become a ninja, fine by me. But I'm only going 'cause you want to. + +Jay: Oh, great. He's going. We saved the future! + +Zane: Not so fast. We still have to make sure he becomes a ninja in order for everything to get back on track. + +(The ninja follow them. Meanwhile, the Skulkin are driving away when they came across two trees.) + +Past Nuckal: Do you see two trees or one? + +Past Kruncha: Two, you imbecile! + +Past Samukai: Stop fooling around! We failed our mission and now Lord Garmadon is really going to let us have it. + +Past Nuckal: Well, I just wanted to know if I was seeing double. I thought I saw the same ninja twice back there. + +Garmadon: Double? So the ninja followed me here, and they think they can stop me? They are as annoying as they are troublesome! Huh. Well, two can play at this game. + +(At the Monastery, Nya has started training, but Kai isn't interested as the Present Ninjas watched.) + +Past Wu: You must harness the fire deep within you, Kai. + +Past Kai: (Gets hit by a punching bag) Ow! Ah. Yeah, yeah, yeah. + +Kai: Oh, I'm worthless. + +Jay: It's like you don't even want to be a ninja. + +Kai: Look, I told you: The only reason I became a ninja was to rescue my sister. + +Zane: If Kai doesn't learn to be a ninja, our future will be destroyed. + +Cole: Ugh. Even when Garmadon does something good, it's always evil! Sensei ain't cutting the mustard. Guess it's up to us to get things back on track. + +Jay: I thought you said not to get involved. "Change anything, change everything." + +Cole: That was before Garmadon altered the past and changed the future. Now we gotta clean up the mess. + +Zane: He's right. The only way to save our future is for Kai to find his fire. + +Jay: But, what are you trying to say, Zane? + +Zane: I'm saying we have to give Kai his motivation back. We have to kidnap Nya and deliver her into the hands of the Skeleton. + +Kai: (Sighs) If that's what it takes. Okay, but if it's all right with you guys, I’ve got an idea on how I could talk some sense into myself. + +(At night, the ninja, except Kai sneak into Nya's room. After they approach her bed, they stop.) + +Cole: Time to play our parts. (He hands them Skeleton masks.) + +Zane: We'll have to act quickly while Kai is keeping himself busy. + +(Both Kais are in the bathroom. Present Kai takes off the mirror and copies his Past self's movements.) + +Past Kai: Huh? (He blows on the "mirror" and tries to clean it.) + +Kai: Hey, dummy! + +Past Kai: Aah! + +Kai: (Offscreen) Yeah, I'm talking to you. + +Past Kai: Me? + +Kai: Would you get your head on straight and start listening to Sensei? The fate of the world kind of rests on it. + +Past Kai: Huh? + +(The others now have Nya.) + +Nya: (Now in a bag) Unhand me, Skeletons! + +Jay: Oh, I'm sorry, Nya. + +Nya: (In a bag) How'd you know my name? Oh, when my brother finds out about this, you'll be sorry! + +Cole: Actually, he'll be quite relieved. + +Zane: (Spotting their past selves) Huh, guys, we've got company. + +(Just then, their past selves notices their present selves.) + +Jay: Uh, isn't that, uh, us? + +Cole: Better lower our masks so they don't recognize us. (Jay fights his past self.) Let's not hurt ourselves, boys. (Zane puts away his weapons.) I'd hate to leave a mark. + +Past Nya: Kai, help! + +Past Kai: Huh, Nya! + +Past Nya: Help! (Past Kai runs to find her.) + +Kai: Hey, I wasn't done talking to you. You can't leave. + +Past Kai: Why not? You're just my reflection! (Present Kai hits him hard, knocking him unconscious.) + +Kai: Sorry, but I can't have you spoiling our plans. (He goes out and helps the ninja.) + +Jay: Oh, why does time travel have to be so complicated? + +(The sack that has Nya falls, but only to be caught by Present Kai. The Red Ninja then goes to the rooftop.) + +Kai: Guys, uh (deepens voice) let's get out of here. + +(Jay, Cole and Zane knocks their past selves to the floor as Past Wu ran outside along with Past Kat.) + +Past Wu: They took Nya! + +Past Kai: (Now awake) Huh? They took my sister? NOOOOOO!!!!!! + +(In the present, Lloyd's picture comes back. Wu sighs of relief.) + +Past Wu: (Comforts him) We'll get your sister back. But first, I want you to meet your team. (The past ninja unmask themselves.) + +Kai: Looks like the fire returned. (He and his friends leave) + +(In the forest, the present ninja were heading to the Skulkins as they jumped on the tree branches one by one while carrying a bag with Nya in it.) + +Cole: All we gotta do to get everything back on track is to deliver Nya to the Skeletons. + +(The Skulkin have set up camp. Past Samukai hits Past Kruncha.) + +Past Kruncha: Hey! + +Past Samukai: This is no time to celebrate, we didn't get the girl. How would you like to go back to the Underworld to tell Lord Garmadon that we failed? + +Past Kruncha: (Walking away and mocking him) "How would you tell Lord Garmadon that we failed?" Mimimimi! (Nya screams as she is being dropped by Present Kai.) Huh? (He looks up before checks inside the bag to see Nya struggling.) Uh, Samukai? How would you like to go back to the Underworld to tell him we succeeded? + +(At the Underworld, Samukai and Garmadon converse.) + +Past Samukai: Master, we have the girl. + +Past Garmadon: Good. (He laughs evilly; as Samukai leaves, present Garmadon approaches him.) Hmm? What is the meaning of this? + +Garmadon: It is I, your future self! + +Past Garmadon: How can it be? + +Garmadon: I know you are searching for the Golden Weapons, and it is because of them that I have gone back in time and stand before you now. + +Past Garmadon: I don't understand. You have...uh, I have four arms. + +Garmadon: Yes, and I need your help to get rid of the ninja once and for all. Listen carefully. (He whispers to him.) + +Past Garmadon: That is truly evil. + +(They both laugh evilly. In the present, Wu sees the city being covered in more smoke.) + +Wu: Great Scott! + +(In the past, the Past ninja sleep while the Present ninja and Past Wu watches.) + +Jay: Ugh, why are we still here? I thought everything is back on track. + +Cole: Yeah, but we have to stay and make sure all goes as planned. + +Past Wu: So what exactly happens next? + +Kai: Our former selves have most of the Golden Weapons. Shadow Garmadon now should have my sister. And in about one minute, I get woken up and tricked into taking the Sword of Fire that will eventually unleash the greatest nemesis unto Ninjago. + +Past Kai: Nya, wait up! + +(They run to the Fire Temple.) + +Kai: Ah. This is when I steal the Fire Sword and cut my sister down. Then I get attacked by my own shadow. + +(Past Nya, on a chain over lava, gets lowered.) + +Past Nya: Aah! + +Past Kai: Nya! + +Past Garmadon: Tick tock, tick tock. + +Past Kai: Ninja, go! (He grabs the Sword and cuts Nya down. Past Garmadon laughs.) + +Past Kai: Stay close. + +Past Nya: Trust me. I'm not going anywhere. + +Past Kai: You can't hurt us! You're only a shadow! + +Past Garmadon: This time, I'm actually much more! + +Kai: Wait, he never said that before. Something's different. + +(Present Garmadon reveals himself.) + +Past Kai: G-Garmadon? But you're supposed to be trapped in the Underworld. + +Garmadon: Oh, there's so much more you don't know. + +Past Garmadon: Go, future me. Destroy him! (Present Garmadon laughs as he and Past Kai start fighting.) + +Kai: Ugh, we have to do something. I don't stand a chance against Garmadon's four arms. Wait, that's it! (He jumps in front of Past Kai.) + +Past Garmadon: Two Kais? + +Past Kai: Yeah. + +Kai: I guess I have four arms too. + +Cole: Now, I've got you. (He grabs his Weapon, but Garmadon overpowers him.) Aah! I think it's safe to say the past has drastically changed. + +(Both Garmadons laugh triumphantly.) + +Zane: I was thinking about that. It could be possible to erase the events that have transpired so far by destroying the Mega Weapon that brought us here in the first place. + +Kai: You mean if we destroy that weapon, everything goes back to normal? + +Zane: Uh, theoretically. + +Cole: But nothing can destroy the Mega Weapon. Only a weapon of equal power has any chance of stopping it. + +Jay: Haha, like maybe the weapons forged to create it in the first place? (He reveals to them he brought the Golden Weapons.) + +Garmadon: No! + +Cole: The Golden Weapons! + +Jay: Yeah, I thought these might come in handy. + +Kai: Okay, no time to find out how you got these. We have to destroy the Mega Weapon once and for all. Fire! + +Jay: Lightning! + +Cole: Earth! + +Zane: Ice! + +Garmadon: No! What are you doing? + +Past Wu: Ninja, go! (The Weapon gets shot into space. Nya slowly fades away.) + +Past Kai: Nya! What's happening!? (He and Past Wu disappear as well. The ninja are sent back to the present.) + +Jay: Did we just, uh...? + +Kai: Return to the future? + +Cole: This feels familiar, but weren't we training Lloyd? + +Zane: The future was altered and there is no Green Ninja. + +Lloyd: (Panting) Sorry I'm late, guys. Are we ready to get training? + +Kai: Ha! You're still here! + +Lloyd: Uh, yeah. Why wouldn't I be? + +Jay: So when we went back in time and destroyed Garmadon's Mega Weapon, that didn't change anything? (Kai goes to grab a watermelon.) + +Lloyd: My father has a Mega Weapon? + +Kai: Oh, wait a minute, you've never heard of it? (He slices the melon, giving one to everyone.) + +Lloyd: I-I think I would've remembered that. Why? What is it? Should I be concerned? (Everyone laughs at him.) Hey, what are you guys laughing at? What's so funny? (He carves a Dragon on his slice.) + +Kai: Hahaha, I think some things are best left in the past. Come on. Let's just prepare for the future. (Everyone laughs.) + +Lloyd: I don't get it. + +(Outside, Wu looks at the star the Golden Weapons shot up to.) diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..dbce39d --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,188 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.11 + + + + com.truthlens + backend + 0.0.1-SNAPSHOT + TruthLens Backend + Backend API for TruthLens using Spring Boot and Keycloak + + + 25 + 2025.0.1 + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.postgresql + postgresql + runtime + + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + + io.github.cdimascio + dotenv-java + 3.0.0 + + + + + org.springframework.cloud + spring-cloud-starter-config + + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-client + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.5 + + + + org.springframework.retry + spring-retry + + + org.springframework.boot + spring-boot-starter-aop + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + + io.cucumber + cucumber-java + 7.22.0 + test + + + io.cucumber + cucumber-spring + 7.22.0 + test + + + io.cucumber + cucumber-junit-platform-engine + 7.22.0 + test + + + org.junit.platform + junit-platform-suite + test + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + org.springframework.boot + spring-boot-starter-data-mongodb + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + --enable-preview + + + + + org.apache.maven.plugins + maven-surefire-plugin + + --enable-preview ${argLine} + + + + org.jacoco + jacoco-maven-plugin + 0.8.14 + + + + prepare-agent + + + + report + verify + + report + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/backend/src/main/java/com/truthlens/backend/TruthLensApplication.java b/backend/src/main/java/com/truthlens/backend/TruthLensApplication.java new file mode 100644 index 0000000..a773459 --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/TruthLensApplication.java @@ -0,0 +1,22 @@ +package com.truthlens.backend; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import io.github.cdimascio.dotenv.Dotenv; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; + +@SpringBootApplication +@EnableDiscoveryClient +public class TruthLensApplication { + + public static void main(String[] args) { + Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load(); + dotenv.entries().forEach(entry -> { + if (System.getProperty(entry.getKey()) == null) { + System.setProperty(entry.getKey(), entry.getValue()); + } + }); + + SpringApplication.run(TruthLensApplication.class, args); + } +} diff --git a/backend/src/main/java/com/truthlens/backend/client/GroqApiClient.java b/backend/src/main/java/com/truthlens/backend/client/GroqApiClient.java new file mode 100644 index 0000000..5a7856c --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/client/GroqApiClient.java @@ -0,0 +1,15 @@ +package com.truthlens.backend.client; + +import com.truthlens.backend.model.jury.GroqChatRequest; +import com.truthlens.backend.model.jury.GroqChatResponse; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.service.annotation.PostExchange; + +public interface GroqApiClient { + @PostExchange("/chat/completions") + GroqChatResponse getChatCompletion( + @RequestHeader("Authorization") String authorization, + @RequestBody GroqChatRequest request + ); +} diff --git a/backend/src/main/java/com/truthlens/backend/client/OcrServiceClient.java b/backend/src/main/java/com/truthlens/backend/client/OcrServiceClient.java new file mode 100644 index 0000000..22cc8be --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/client/OcrServiceClient.java @@ -0,0 +1,12 @@ +package com.truthlens.backend.client; + +import com.truthlens.backend.model.OcrExtractionResponse; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.service.annotation.PostExchange; + +public interface OcrServiceClient { + + @PostExchange(url = "/api/internal/v1/ocr:extract", contentType = MediaType.MULTIPART_FORM_DATA_VALUE) + OcrExtractionResponse extractText(@RequestPart("image") org.springframework.core.io.Resource image); +} diff --git a/backend/src/main/java/com/truthlens/backend/client/SearchServiceClient.java b/backend/src/main/java/com/truthlens/backend/client/SearchServiceClient.java new file mode 100644 index 0000000..7bab1f0 --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/client/SearchServiceClient.java @@ -0,0 +1,12 @@ +package com.truthlens.backend.client; + +import com.truthlens.backend.model.SearchExecutionRequest; +import com.truthlens.backend.model.SearchExecutionResponse; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.service.annotation.PostExchange; + +public interface SearchServiceClient { + + @PostExchange("/api/internal/v1/search:execute") + SearchExecutionResponse executeSearch(@RequestBody SearchExecutionRequest request); +} diff --git a/backend/src/main/java/com/truthlens/backend/config/GroqClientConfig.java b/backend/src/main/java/com/truthlens/backend/config/GroqClientConfig.java new file mode 100644 index 0000000..faac013 --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/config/GroqClientConfig.java @@ -0,0 +1,24 @@ +package com.truthlens.backend.config; + +import com.truthlens.backend.client.GroqApiClient; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +@Configuration +@EnableConfigurationProperties(GroqProperties.class) +public class GroqClientConfig { + + @Bean + public GroqApiClient groqApiClient(GroqProperties groqProperties) { + RestClient restClient = RestClient.builder() + .baseUrl(groqProperties.apiUrl()) + .build(); + RestClientAdapter adapter = RestClientAdapter.create(restClient); + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build(); + return factory.createClient(GroqApiClient.class); + } +} diff --git a/backend/src/main/java/com/truthlens/backend/config/GroqProperties.java b/backend/src/main/java/com/truthlens/backend/config/GroqProperties.java new file mode 100644 index 0000000..d541dc0 --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/config/GroqProperties.java @@ -0,0 +1,13 @@ +package com.truthlens.backend.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@ConfigurationProperties(prefix = "groq") +public record GroqProperties( + String apiUrl, + String apiKey, + List models +) { +} diff --git a/backend/src/main/java/com/truthlens/backend/config/HttpClientConfig.java b/backend/src/main/java/com/truthlens/backend/config/HttpClientConfig.java new file mode 100644 index 0000000..c7c7a04 --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/config/HttpClientConfig.java @@ -0,0 +1,36 @@ +package com.truthlens.backend.config; + +import com.truthlens.backend.client.OcrServiceClient; +import com.truthlens.backend.client.SearchServiceClient; +import org.springframework.cloud.client.loadbalancer.LoadBalanced; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +@Configuration +public class HttpClientConfig { + + @Bean + @LoadBalanced + public RestClient.Builder loadBalancedRestClientBuilder() { + return RestClient.builder(); + } + + @Bean + public SearchServiceClient searchServiceClient(RestClient.Builder builder) { + RestClient restClient = builder.baseUrl("http://truthlens-search-service").build(); + RestClientAdapter adapter = RestClientAdapter.create(restClient); + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build(); + return factory.createClient(SearchServiceClient.class); + } + + @Bean + public OcrServiceClient ocrServiceClient(RestClient.Builder builder) { + RestClient restClient = builder.baseUrl("http://truthlens-ocr-service").build(); + RestClientAdapter adapter = RestClientAdapter.create(restClient); + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build(); + return factory.createClient(OcrServiceClient.class); + } +} diff --git a/backend/src/main/java/com/truthlens/backend/config/OpenApiConfig.java b/backend/src/main/java/com/truthlens/backend/config/OpenApiConfig.java new file mode 100644 index 0000000..575cd2c --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/config/OpenApiConfig.java @@ -0,0 +1,24 @@ +package com.truthlens.backend.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.annotations.servers.Server; +import org.springframework.context.annotation.Configuration; + +@Configuration +@OpenAPIDefinition( + info = @Info(title = "TruthLens Backend API", version = "v1"), + security = @SecurityRequirement(name = "bearerAuth"), + servers = {@Server(url = "/", description = "Default Server URL")} +) +@SecurityScheme( + name = "bearerAuth", + type = SecuritySchemeType.HTTP, + bearerFormat = "JWT", + scheme = "bearer" +) +public class OpenApiConfig { +} diff --git a/backend/src/main/java/com/truthlens/backend/config/RedisConfig.java b/backend/src/main/java/com/truthlens/backend/config/RedisConfig.java new file mode 100644 index 0000000..f144641 --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/config/RedisConfig.java @@ -0,0 +1,23 @@ +package com.truthlens.backend.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + return template; + } +} diff --git a/backend/src/main/java/com/truthlens/backend/config/SecurityConfig.java b/backend/src/main/java/com/truthlens/backend/config/SecurityConfig.java new file mode 100644 index 0000000..b23d8dd --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/config/SecurityConfig.java @@ -0,0 +1,45 @@ +package com.truthlens.backend.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .cors(cors -> cors.disable()) + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(authz -> authz + .requestMatchers("/api/public/**", "/actuator/health", "/api/v1/health", "/api/v1/verifications/**").permitAll() + .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() + .anyRequest().authenticated()) + .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> { + })); + + return http.build(); + } + + @Bean + CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type")); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/backend/src/main/java/com/truthlens/backend/controller/HistoryController.java b/backend/src/main/java/com/truthlens/backend/controller/HistoryController.java new file mode 100644 index 0000000..bc3813f --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/controller/HistoryController.java @@ -0,0 +1,30 @@ +package com.truthlens.backend.controller; + +import com.truthlens.backend.model.VerificationHistory; +import com.truthlens.backend.service.VerificationHistoryService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/history") +public class HistoryController { + + private final VerificationHistoryService historyService; + + public HistoryController(VerificationHistoryService historyService) { + this.historyService = historyService; + } + + @GetMapping + public ResponseEntity> getHistory( + @AuthenticationPrincipal Jwt jwt, + Pageable pageable) { + return ResponseEntity.ok(historyService.getHistory(jwt.getSubject(), pageable)); + } +} diff --git a/backend/src/main/java/com/truthlens/backend/controller/SystemHealthController.java b/backend/src/main/java/com/truthlens/backend/controller/SystemHealthController.java new file mode 100644 index 0000000..8943162 --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/controller/SystemHealthController.java @@ -0,0 +1,32 @@ +package com.truthlens.backend.controller; + +import org.springframework.cloud.client.discovery.DiscoveryClient; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +public class SystemHealthController { + + private final DiscoveryClient discoveryClient; + + public SystemHealthController(DiscoveryClient discoveryClient) { + this.discoveryClient = discoveryClient; + } + + @GetMapping("/api/v1/health") + public ResponseEntity> getHealth() { + boolean ocrUp = !discoveryClient.getInstances("truthlens-ocr-service").isEmpty(); + boolean searchUp = !discoveryClient.getInstances("truthlens-search-service").isEmpty(); + + boolean allUp = ocrUp && searchUp; + + return ResponseEntity.ok(Map.of( + "status", allUp ? "UP" : "DOWN", + "ocr", ocrUp ? "UP" : "DOWN", + "search", searchUp ? "UP" : "DOWN" + )); + } +} diff --git a/backend/src/main/java/com/truthlens/backend/controller/UserController.java b/backend/src/main/java/com/truthlens/backend/controller/UserController.java new file mode 100644 index 0000000..f5a2516 --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/controller/UserController.java @@ -0,0 +1,41 @@ +package com.truthlens.backend.controller; + +import com.truthlens.backend.model.User; +import com.truthlens.backend.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; + +import java.util.Optional; + +@RestController +@RequestMapping("/api") +public class UserController { + + @Autowired + private UserRepository userRepository; + + @GetMapping("/public/status") + public ResponseEntity getStatus() { + return ResponseEntity.ok("TruthLens Backend is running and connected to DB (tested implicitly on startup)"); + } + + @GetMapping("/users/me") + public ResponseEntity getCurrentUser(@AuthenticationPrincipal Jwt jwt) { + String keycloakId = jwt.getSubject(); + String email = jwt.getClaimAsString("email"); + String firstName = jwt.getClaimAsString("given_name"); + String lastName = jwt.getClaimAsString("family_name"); + + Optional optionalUser = userRepository.findByKeycloakId(keycloakId); + + User user = optionalUser.orElseGet(() -> { + User newUser = new User(keycloakId, email, firstName, lastName); + return userRepository.save(newUser); + }); + + return ResponseEntity.ok(user); + } +} diff --git a/backend/src/main/java/com/truthlens/backend/controller/VerificationController.java b/backend/src/main/java/com/truthlens/backend/controller/VerificationController.java new file mode 100644 index 0000000..8e9af9c --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/controller/VerificationController.java @@ -0,0 +1,52 @@ +package com.truthlens.backend.controller; + +import com.truthlens.backend.model.VerificationRequest; +import com.truthlens.backend.model.VerificationResponse; +import com.truthlens.backend.service.VerificationOrchestratorService; +import com.truthlens.backend.service.VerificationStateService; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/api/v1/verifications") +public class VerificationController { + + private final VerificationOrchestratorService orchestratorService; + private final VerificationStateService stateService; + + public VerificationController(VerificationOrchestratorService orchestratorService, VerificationStateService stateService) { + this.orchestratorService = orchestratorService; + this.stateService = stateService; + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity verifyText(@RequestBody VerificationRequest request, + @AuthenticationPrincipal Jwt jwt) { + String userId = jwt != null ? jwt.getSubject() : null; + VerificationResponse response = orchestratorService.startVerification(request.claimText(), userId); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(response); + } + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity verifyImage(@RequestParam("file") MultipartFile file, + @AuthenticationPrincipal Jwt jwt) { + String userId = jwt != null ? jwt.getSubject() : null; + VerificationResponse response = orchestratorService.startVerification(file, userId); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(response); + } + + @GetMapping("/{id}") + public ResponseEntity getStatus(@PathVariable("id") String id) { + VerificationResponse response = stateService.getState(id); + if (response == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(response); + } +} + diff --git a/backend/src/main/java/com/truthlens/backend/model/JuryReport.java b/backend/src/main/java/com/truthlens/backend/model/JuryReport.java new file mode 100644 index 0000000..d286b3f --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/model/JuryReport.java @@ -0,0 +1,4 @@ +package com.truthlens.backend.model; + +public record JuryReport(String finalVerdict, double averageConfidence, String aggregatedReasoning) { +} diff --git a/backend/src/main/java/com/truthlens/backend/model/ModelVoteResult.java b/backend/src/main/java/com/truthlens/backend/model/ModelVoteResult.java new file mode 100644 index 0000000..bcdf58c --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/model/ModelVoteResult.java @@ -0,0 +1,9 @@ +package com.truthlens.backend.model; + +public record ModelVoteResult( + String modelName, + String verdict, + Integer confidenceScore, + String reasoning +) { +} diff --git a/backend/src/main/java/com/truthlens/backend/model/OcrExtractionResponse.java b/backend/src/main/java/com/truthlens/backend/model/OcrExtractionResponse.java new file mode 100644 index 0000000..7d2939f --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/model/OcrExtractionResponse.java @@ -0,0 +1,4 @@ +package com.truthlens.backend.model; + +public record OcrExtractionResponse(String extractedText, boolean hasManipulationArtifacts, double confidenceScore) { +} diff --git a/backend/src/main/java/com/truthlens/backend/model/SearchExecutionRequest.java b/backend/src/main/java/com/truthlens/backend/model/SearchExecutionRequest.java new file mode 100644 index 0000000..bda73da --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/model/SearchExecutionRequest.java @@ -0,0 +1,6 @@ +package com.truthlens.backend.model; + +import java.util.List; + +public record SearchExecutionRequest(List queries, int maxResultsPerQuery) { +} diff --git a/backend/src/main/java/com/truthlens/backend/model/SearchExecutionResponse.java b/backend/src/main/java/com/truthlens/backend/model/SearchExecutionResponse.java new file mode 100644 index 0000000..621d3e2 --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/model/SearchExecutionResponse.java @@ -0,0 +1,6 @@ +package com.truthlens.backend.model; + +import java.util.List; + +public record SearchExecutionResponse(List results) { +} diff --git a/backend/src/main/java/com/truthlens/backend/model/SearchResult.java b/backend/src/main/java/com/truthlens/backend/model/SearchResult.java new file mode 100644 index 0000000..3fcad44 --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/model/SearchResult.java @@ -0,0 +1,4 @@ +package com.truthlens.backend.model; + +public record SearchResult(String title, String url, String snippet) { +} diff --git a/backend/src/main/java/com/truthlens/backend/model/User.java b/backend/src/main/java/com/truthlens/backend/model/User.java new file mode 100644 index 0000000..898deae --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/model/User.java @@ -0,0 +1,56 @@ +package com.truthlens.backend.model; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "users") +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String keycloakId; + + @Column(nullable = false, unique = true) + private String email; + + private String firstName; + private String lastName; + + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + public User() {} + + public User(String keycloakId, String email, String firstName, String lastName) { + this.keycloakId = keycloakId; + this.email = email; + this.firstName = firstName; + this.lastName = lastName; + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getKeycloakId() { return keycloakId; } + public void setKeycloakId(String keycloakId) { this.keycloakId = keycloakId; } + + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + + public String getFirstName() { return firstName; } + public void setFirstName(String firstName) { this.firstName = firstName; } + + public String getLastName() { return lastName; } + public void setLastName(String lastName) { this.lastName = lastName; } + + public LocalDateTime getCreatedAt() { return createdAt; } + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + } +} diff --git a/backend/src/main/java/com/truthlens/backend/model/VerificationHistory.java b/backend/src/main/java/com/truthlens/backend/model/VerificationHistory.java new file mode 100644 index 0000000..772f1a8 --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/model/VerificationHistory.java @@ -0,0 +1,69 @@ +package com.truthlens.backend.model; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; + +@Document(collection = "verification_history") +public class VerificationHistory { + + @Id + private String id; + + @Indexed + private String userId; + + private String inputType; + private String inputText; + private String fileName; + + private String finalVerdict; + private double averageConfidence; + private String aggregatedReasoning; + + @Indexed + private LocalDateTime createdAt; + + public VerificationHistory() {} + + public VerificationHistory(String userId, String inputType, String inputText, String fileName, + String finalVerdict, double averageConfidence, String aggregatedReasoning) { + this.userId = userId; + this.inputType = inputType; + this.inputText = inputText; + this.fileName = fileName; + this.finalVerdict = finalVerdict; + this.averageConfidence = averageConfidence; + this.aggregatedReasoning = aggregatedReasoning; + this.createdAt = LocalDateTime.now(); + } + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } + + public String getInputType() { return inputType; } + public void setInputType(String inputType) { this.inputType = inputType; } + + public String getInputText() { return inputText; } + public void setInputText(String inputText) { this.inputText = inputText; } + + public String getFileName() { return fileName; } + public void setFileName(String fileName) { this.fileName = fileName; } + + public String getFinalVerdict() { return finalVerdict; } + public void setFinalVerdict(String finalVerdict) { this.finalVerdict = finalVerdict; } + + public double getAverageConfidence() { return averageConfidence; } + public void setAverageConfidence(double averageConfidence) { this.averageConfidence = averageConfidence; } + + public String getAggregatedReasoning() { return aggregatedReasoning; } + public void setAggregatedReasoning(String aggregatedReasoning) { this.aggregatedReasoning = aggregatedReasoning; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/backend/src/main/java/com/truthlens/backend/model/VerificationRequest.java b/backend/src/main/java/com/truthlens/backend/model/VerificationRequest.java new file mode 100644 index 0000000..e514c6d --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/model/VerificationRequest.java @@ -0,0 +1,4 @@ +package com.truthlens.backend.model; + +public record VerificationRequest(String claimText) { +} diff --git a/backend/src/main/java/com/truthlens/backend/model/VerificationResponse.java b/backend/src/main/java/com/truthlens/backend/model/VerificationResponse.java new file mode 100644 index 0000000..450d4ef --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/model/VerificationResponse.java @@ -0,0 +1,13 @@ +package com.truthlens.backend.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record VerificationResponse( + String verificationId, + VerificationStatus status, + Integer progressPercentage, + String message, + JuryReport result +) { +} diff --git a/backend/src/main/java/com/truthlens/backend/model/VerificationStatus.java b/backend/src/main/java/com/truthlens/backend/model/VerificationStatus.java new file mode 100644 index 0000000..9692125 --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/model/VerificationStatus.java @@ -0,0 +1,11 @@ +package com.truthlens.backend.model; + +public enum VerificationStatus { + QUEUED, + OCR_PROCESSING, + GENERATING_QUERIES, + SEARCHING_WEB, + AI_JURY_VOTING, + COMPLETED, + FAILED +} diff --git a/backend/src/main/java/com/truthlens/backend/model/jury/GroqChatRequest.java b/backend/src/main/java/com/truthlens/backend/model/jury/GroqChatRequest.java new file mode 100644 index 0000000..7721d28 --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/model/jury/GroqChatRequest.java @@ -0,0 +1,15 @@ +package com.truthlens.backend.model.jury; + +import java.util.List; +import java.util.Map; + +public record GroqChatRequest( + String model, + List messages, + Double temperature, + Map response_format +) { + public static GroqChatRequest createJsonRequest(String model, List messages) { + return new GroqChatRequest(model, messages, 0.2, Map.of("type", "json_object")); + } +} diff --git a/backend/src/main/java/com/truthlens/backend/model/jury/GroqChatResponse.java b/backend/src/main/java/com/truthlens/backend/model/jury/GroqChatResponse.java new file mode 100644 index 0000000..4b2ebf4 --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/model/jury/GroqChatResponse.java @@ -0,0 +1,19 @@ +package com.truthlens.backend.model.jury; + +import java.util.List; + +public record GroqChatResponse( + List choices +) { + public record Choice( + GroqMessage message + ) { + } + + public String extractContent() { + if (choices != null && !choices.isEmpty() && choices.get(0).message() != null) { + return choices.get(0).message().content(); + } + return null; + } +} diff --git a/backend/src/main/java/com/truthlens/backend/model/jury/GroqMessage.java b/backend/src/main/java/com/truthlens/backend/model/jury/GroqMessage.java new file mode 100644 index 0000000..b9e542a --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/model/jury/GroqMessage.java @@ -0,0 +1,7 @@ +package com.truthlens.backend.model.jury; + +public record GroqMessage( + String role, + String content +) { +} diff --git a/backend/src/main/java/com/truthlens/backend/repository/UserRepository.java b/backend/src/main/java/com/truthlens/backend/repository/UserRepository.java new file mode 100644 index 0000000..26eaa43 --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/repository/UserRepository.java @@ -0,0 +1,13 @@ +package com.truthlens.backend.repository; + +import com.truthlens.backend.model.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByKeycloakId(String keycloakId); + Optional findByEmail(String email); +} diff --git a/backend/src/main/java/com/truthlens/backend/repository/VerificationHistoryRepository.java b/backend/src/main/java/com/truthlens/backend/repository/VerificationHistoryRepository.java new file mode 100644 index 0000000..bf80db1 --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/repository/VerificationHistoryRepository.java @@ -0,0 +1,13 @@ +package com.truthlens.backend.repository; + +import com.truthlens.backend.model.VerificationHistory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface VerificationHistoryRepository extends MongoRepository { + Page findByUserIdOrderByCreatedAtDesc(String userId, Pageable pageable); + Page findByUserIdOrderByCreatedAtAsc(String userId, Pageable pageable); +} diff --git a/backend/src/main/java/com/truthlens/backend/service/GroqLlmService.java b/backend/src/main/java/com/truthlens/backend/service/GroqLlmService.java new file mode 100644 index 0000000..4c8bb62 --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/service/GroqLlmService.java @@ -0,0 +1,40 @@ +package com.truthlens.backend.service; + +import com.truthlens.backend.client.GroqApiClient; +import com.truthlens.backend.config.GroqProperties; +import com.truthlens.backend.model.jury.GroqChatRequest; +import com.truthlens.backend.model.jury.GroqChatResponse; +import com.truthlens.backend.model.jury.GroqMessage; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class GroqLlmService { + + private final GroqApiClient groqApiClient; + private final GroqProperties groqProperties; + + public GroqLlmService(GroqApiClient groqApiClient, GroqProperties groqProperties) { + this.groqApiClient = groqApiClient; + this.groqProperties = groqProperties; + } + + @Retryable( + retryFor = {Exception.class}, + maxAttempts = 3, + backoff = @Backoff(delay = 1000, multiplier = 2.0) + ) + public String askModel(String modelName, String prompt) { + String authHeader = "Bearer " + groqProperties.apiKey(); + List messages = List.of( + new GroqMessage("user", prompt) + ); + GroqChatRequest request = GroqChatRequest.createJsonRequest(modelName, messages); + + GroqChatResponse response = groqApiClient.getChatCompletion(authHeader, request); + return response.extractContent(); + } +} diff --git a/backend/src/main/java/com/truthlens/backend/service/JuryVotingService.java b/backend/src/main/java/com/truthlens/backend/service/JuryVotingService.java new file mode 100644 index 0000000..f476ad6 --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/service/JuryVotingService.java @@ -0,0 +1,73 @@ +package com.truthlens.backend.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.truthlens.backend.config.GroqProperties; +import com.truthlens.backend.model.ModelVoteResult; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.StructuredTaskScope; + +@Service +public class JuryVotingService { + + private final GroqLlmService groqLlmService; + private final GroqProperties groqProperties; + private final ObjectMapper objectMapper; + + public JuryVotingService(GroqLlmService groqLlmService, GroqProperties groqProperties, ObjectMapper objectMapper) { + this.groqLlmService = groqLlmService; + this.groqProperties = groqProperties; + this.objectMapper = objectMapper; + } + + public List gatherVotes(String claimText, String searchEvidence) { + String prompt = """ + Please verify the fact based on the following claim and evidence. + Return the response EXCLUSIVELY in JSON format with the following keys: + - "verdict": (integer from 0 meaning completely false to 100 meaning completely true) + - "confidenceScore": (integer from 0 to 100) + - "reasoning": (short explanation) + + Claim: %s + Internet evidence: %s + """.formatted(claimText, searchEvidence); + + List results = new ArrayList<>(); + List models = groqProperties.models(); + + try (var scope = StructuredTaskScope.open(StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow())) { + List> subtasks = models.stream() + .map(model -> scope.fork(() -> { + String rawResponse = groqLlmService.askModel(model, prompt); + + try { + JsonNode node = objectMapper.readTree(rawResponse); + String verdict = node.has("verdict") ? node.get("verdict").asText() : "UNKNOWN"; + Integer confidenceScore = node.has("confidenceScore") ? node.get("confidenceScore").asInt() + : 0; + String reasoning = node.has("reasoning") ? node.get("reasoning").asText() : rawResponse; + + return new ModelVoteResult(model, verdict, confidenceScore, reasoning); + } catch (Exception e) { + return new ModelVoteResult(model, "ERROR", 0, "Failed to parse JSON: " + rawResponse); + } + })) + .toList(); + + scope.join(); + + subtasks.forEach(subtask -> results.add(subtask.get())); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Jury voting was interrupted", e); + } catch (Exception e) { + throw new RuntimeException("Jury voting failed", e); + } + + return results; + } +} diff --git a/backend/src/main/java/com/truthlens/backend/service/VerificationHistoryService.java b/backend/src/main/java/com/truthlens/backend/service/VerificationHistoryService.java new file mode 100644 index 0000000..788ee65 --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/service/VerificationHistoryService.java @@ -0,0 +1,40 @@ +package com.truthlens.backend.service; + +import com.truthlens.backend.model.JuryReport; +import com.truthlens.backend.model.VerificationHistory; +import com.truthlens.backend.repository.VerificationHistoryRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +@Service +public class VerificationHistoryService { + + private static final Logger log = LoggerFactory.getLogger(VerificationHistoryService.class); + + private final VerificationHistoryRepository historyRepository; + + public VerificationHistoryService(VerificationHistoryRepository historyRepository) { + this.historyRepository = historyRepository; + } + + public void saveHistory(String userId, String inputType, String inputText, String fileName, JuryReport report) { + VerificationHistory entry = new VerificationHistory( + userId, + inputType, + inputText, + fileName, + report.finalVerdict(), + report.averageConfidence(), + report.aggregatedReasoning() + ); + historyRepository.save(entry); + log.info("Saved verification history for user {} (verdict: {})", userId, report.finalVerdict()); + } + + public Page getHistory(String userId, Pageable pageable) { + return historyRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); + } +} diff --git a/backend/src/main/java/com/truthlens/backend/service/VerificationOrchestratorService.java b/backend/src/main/java/com/truthlens/backend/service/VerificationOrchestratorService.java new file mode 100644 index 0000000..e91aabb --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/service/VerificationOrchestratorService.java @@ -0,0 +1,199 @@ +package com.truthlens.backend.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.truthlens.backend.client.OcrServiceClient; +import com.truthlens.backend.client.SearchServiceClient; +import com.truthlens.backend.model.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; + +@Service +public class VerificationOrchestratorService { + + private static final Logger log = LoggerFactory.getLogger(VerificationOrchestratorService.class); + + private final VerificationStateService stateService; + private final GroqLlmService groqLlmService; + private final JuryVotingService juryVotingService; + private final SearchServiceClient searchServiceClient; + private final OcrServiceClient ocrServiceClient; + private final ObjectMapper objectMapper; + private final VerificationHistoryService historyService; + private final ExecutorService virtualThreadExecutor; + + public VerificationOrchestratorService(VerificationStateService stateService, + GroqLlmService groqLlmService, + JuryVotingService juryVotingService, + SearchServiceClient searchServiceClient, + OcrServiceClient ocrServiceClient, + ObjectMapper objectMapper, + VerificationHistoryService historyService) { + this.stateService = stateService; + this.groqLlmService = groqLlmService; + this.juryVotingService = juryVotingService; + this.searchServiceClient = searchServiceClient; + this.ocrServiceClient = ocrServiceClient; + this.objectMapper = objectMapper; + this.historyService = historyService; + this.virtualThreadExecutor = Executors.newVirtualThreadPerTaskExecutor(); + } + + public VerificationResponse startVerification(String text, String userId) { + String verificationId = UUID.randomUUID().toString(); + stateService.updateState(verificationId, VerificationStatus.QUEUED, 0, "Verification in progress...", + null); + + virtualThreadExecutor.submit(() -> processVerification(verificationId, text, userId, "TEXT", null)); + + return new VerificationResponse(verificationId, VerificationStatus.QUEUED, 0, + "Verification in progress...", null); + } + + public VerificationResponse startVerification(MultipartFile image, String userId) { + String verificationId = UUID.randomUUID().toString(); + stateService.updateState(verificationId, VerificationStatus.QUEUED, 0, "Verification in progress...", + null); + + byte[] imageBytes; + String originalFilename; + try { + imageBytes = image.getBytes(); + originalFilename = image.getOriginalFilename(); + } catch (java.io.IOException e) { + throw new RuntimeException("Failed to read image bytes", e); + } + + virtualThreadExecutor.submit(() -> { + try { + stateService.updateState(verificationId, VerificationStatus.OCR_PROCESSING, 10); + org.springframework.core.io.Resource resource = new org.springframework.core.io.ByteArrayResource( + imageBytes) { + @Override + public String getFilename() { + return originalFilename != null ? originalFilename : "image.png"; + } + }; + OcrExtractionResponse ocrResponse = ocrServiceClient.extractText(resource); + + if (ocrResponse.extractedText() == null || ocrResponse.extractedText().isBlank()) { + throw new RuntimeException("Failed to read text from image."); + } + + processVerification(verificationId, ocrResponse.extractedText(), userId, "IMAGE", originalFilename); + } catch (Exception e) { + log.error("Failed to process image for verification {}", verificationId, e); + stateService.updateState(verificationId, VerificationStatus.FAILED, 0, + "Image processing error: " + e.getMessage(), null); + } + }); + + return new VerificationResponse(verificationId, VerificationStatus.QUEUED, 0, + "Verification in progress...", null); + } + + void processVerification(String verificationId, String text, String userId, String inputType, String fileName) { + try { + stateService.updateState(verificationId, VerificationStatus.GENERATING_QUERIES, 30); + String prompt = "Wygeneruj 3 optymalne zapytania do wyszukiwarki internetowej, aby zweryfikować prawdziwość tego tekstu. Zwróć wyłączenie zserializowaną tablicę JSON stringów i nic więcej (bez formatowania Markdown). Tekst: " + + text; + String rawQueries = groqLlmService.askModel("llama-3.1-8b-instant", prompt); + + rawQueries = rawQueries.replaceAll("```json", "").replaceAll("```", "").trim(); + com.fasterxml.jackson.databind.JsonNode rootNode = objectMapper.readTree(rawQueries); + List queries = new java.util.ArrayList<>(); + if (rootNode.isArray()) { + for (com.fasterxml.jackson.databind.JsonNode node : rootNode) { + queries.add(parseQueryNode(node)); + } + } else if (rootNode.isObject()) { + java.util.Iterator elements = rootNode.elements(); + while (elements.hasNext()) { + com.fasterxml.jackson.databind.JsonNode node = elements.next(); + if (node.isArray()) { + for (com.fasterxml.jackson.databind.JsonNode el : node) { + queries.add(parseQueryNode(el)); + } + break; + } + } + } + queries.removeIf(String::isBlank); + if (queries.isEmpty()) { + queries.add(text); + } + + stateService.updateState(verificationId, VerificationStatus.SEARCHING_WEB, 50); + SearchExecutionResponse searchResponse = searchServiceClient + .executeSearch(new SearchExecutionRequest(queries, 3)); + + String searchEvidence = searchResponse.results().stream() + .map(r -> r.title() + " (" + r.url() + "):\n" + r.snippet()) + .collect(Collectors.joining("\n\n")); + + stateService.updateState(verificationId, VerificationStatus.AI_JURY_VOTING, 70); + List votes = juryVotingService.gatherVotes(text, searchEvidence); + + double avgConfidence = votes.stream().mapToInt(ModelVoteResult::confidenceScore).average().orElse(0.0); + String aggregatedReasoning = votes.stream() + .map(v -> v.modelName() + ": " + v.reasoning()) + .collect(Collectors.joining("\n")); + + long trueVotes = votes.stream().filter(v -> { + try { + return Integer.parseInt(v.verdict()) > 50; + } catch (Exception e) { + return "TRUE".equalsIgnoreCase(v.verdict()); + } + }).count(); + String finalVerdict = (trueVotes >= 2) ? "TRUE" : "FALSE"; + + JuryReport report = new JuryReport(finalVerdict, avgConfidence, aggregatedReasoning); + stateService.updateState(verificationId, VerificationStatus.COMPLETED, 100, + "Verification completed successfully.", report); + + if (userId != null && !userId.isBlank()) { + historyService.saveHistory(userId, inputType, text, fileName, report); + } + + } catch (Exception e) { + log.error("Failed to process verification {}", verificationId, e); + stateService.updateState(verificationId, VerificationStatus.FAILED, 0, + "An error occurred during verification: " + e.getMessage(), null); + } + } + + String parseQueryNode(com.fasterxml.jackson.databind.JsonNode el) { + if (el.isObject()) { + if (el.has("query")) + return el.get("query").asText(); + if (el.has("q")) + return el.get("q").asText(); + if (el.has("zapytanie")) + return el.get("zapytanie").asText(); + } else if (el.isTextual()) { + String text = el.asText().trim(); + if (text.startsWith("{")) { + try { + com.fasterxml.jackson.databind.JsonNode nested = objectMapper.readTree(text); + if (nested.has("query")) + return nested.get("query").asText(); + if (nested.has("q")) + return nested.get("q").asText(); + if (nested.has("zapytanie")) + return nested.get("zapytanie").asText(); + } catch (Exception ignored) { + } + } + return text; + } + return el.asText(); + } +} diff --git a/backend/src/main/java/com/truthlens/backend/service/VerificationStateService.java b/backend/src/main/java/com/truthlens/backend/service/VerificationStateService.java new file mode 100644 index 0000000..3412165 --- /dev/null +++ b/backend/src/main/java/com/truthlens/backend/service/VerificationStateService.java @@ -0,0 +1,45 @@ +package com.truthlens.backend.service; + +import com.truthlens.backend.model.JuryReport; +import com.truthlens.backend.model.VerificationResponse; +import com.truthlens.backend.model.VerificationStatus; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +@Service +public class VerificationStateService { + + private static final Duration KEY_TTL = Duration.ofMinutes(15); + private static final String KEY_PREFIX = "verification:"; + + private final RedisTemplate redisTemplate; + + public VerificationStateService(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + public void updateState(String verificationId, VerificationStatus status, int progressPercentage, String message, JuryReport result) { + VerificationResponse response = new VerificationResponse( + verificationId, + status, + progressPercentage, + message, + result + ); + redisTemplate.opsForValue().set(KEY_PREFIX + verificationId, response, KEY_TTL); + } + + public void updateState(String verificationId, VerificationStatus status, int progressPercentage) { + updateState(verificationId, status, progressPercentage, null, null); + } + + public VerificationResponse getState(String verificationId) { + Object value = redisTemplate.opsForValue().get(KEY_PREFIX + verificationId); + if (value instanceof VerificationResponse) { + return (VerificationResponse) value; + } + return null; + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..1d20f5f --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,18 @@ +spring: + application: + name: truthlens-backend + config: + import: "optional:configserver:http://truthlens-config-server:8888" + cloud: + config: + fail-fast: true + retry: + initial-interval: 2000 + max-interval: 10000 + multiplier: 1.5 + max-attempts: 15 + +eureka: + client: + serviceUrl: + defaultZone: http://truthlens-discovery-server:8761/eureka/ \ No newline at end of file diff --git a/backend/src/test/java/com/truthlens/backend/TruthLensApplicationTests.java b/backend/src/test/java/com/truthlens/backend/TruthLensApplicationTests.java new file mode 100644 index 0000000..edf96ff --- /dev/null +++ b/backend/src/test/java/com/truthlens/backend/TruthLensApplicationTests.java @@ -0,0 +1,13 @@ +package com.truthlens.backend; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class TruthLensApplicationTests { + + @Test + void contextLoads() { + Assertions.assertDoesNotThrow(() -> {}); + } + +} diff --git a/backend/src/test/java/com/truthlens/backend/config/ConfigCoverageTest.java b/backend/src/test/java/com/truthlens/backend/config/ConfigCoverageTest.java new file mode 100644 index 0000000..f31fa98 --- /dev/null +++ b/backend/src/test/java/com/truthlens/backend/config/ConfigCoverageTest.java @@ -0,0 +1,52 @@ +package com.truthlens.backend.config; + +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.web.client.RestClient; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class ConfigCoverageTest { + + @Test + void testGroqProperties() { + GroqProperties properties = new GroqProperties("http://api.groq.com", "key123", List.of("model1")); + assertThat(properties.apiUrl()).isEqualTo("http://api.groq.com"); + assertThat(properties.apiKey()).isEqualTo("key123"); + assertThat(properties.models()).containsExactly("model1"); + } + + @Test + void testGroqClientConfig() { + GroqClientConfig config = new GroqClientConfig(); + GroqProperties properties = new GroqProperties("http://api.groq.com", "key", List.of()); + var client = config.groqApiClient(properties); + assertThat(client).isNotNull(); + } + + @Test + void testHttpClientConfig() { + HttpClientConfig config = new HttpClientConfig(); + + RestClient.Builder builder = config.loadBalancedRestClientBuilder(); + assertThat(builder).isNotNull(); + + var searchClient = config.searchServiceClient(RestClient.builder()); + assertThat(searchClient).isNotNull(); + + var ocrClient = config.ocrServiceClient(RestClient.builder()); + assertThat(ocrClient).isNotNull(); + } + + @Test + void testRedisConfig() { + RedisConfig config = new RedisConfig(); + RedisConnectionFactory factory = mock(RedisConnectionFactory.class); + var template = config.redisTemplate(factory); + assertThat(template).isNotNull(); + assertThat(template.getConnectionFactory()).isEqualTo(factory); + } +} diff --git a/backend/src/test/java/com/truthlens/backend/controller/HistoryControllerTest.java b/backend/src/test/java/com/truthlens/backend/controller/HistoryControllerTest.java new file mode 100644 index 0000000..8bc26fb --- /dev/null +++ b/backend/src/test/java/com/truthlens/backend/controller/HistoryControllerTest.java @@ -0,0 +1,52 @@ +package com.truthlens.backend.controller; + +import com.truthlens.backend.model.VerificationHistory; +import com.truthlens.backend.service.VerificationHistoryService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.test.web.servlet.MockMvc; + +import com.truthlens.backend.config.SecurityConfig; +import org.springframework.context.annotation.Import; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(controllers = HistoryController.class, properties = {"spring.cloud.config.enabled=false", "eureka.client.enabled=false"}) +@Import(SecurityConfig.class) +class HistoryControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private VerificationHistoryService historyService; + + @MockitoBean + private JwtDecoder jwtDecoder; + + @Test + void testGetHistory() throws Exception { + VerificationHistory history = new VerificationHistory(); + history.setId("hist-id"); + history.setInputText("test text"); + Page page = new PageImpl<>(List.of(history)); + + when(historyService.getHistory(any(), any(Pageable.class))).thenReturn(page); + + mockMvc.perform(get("/api/v1/history?page=0&size=10") + .with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].id").value("hist-id")) + .andExpect(jsonPath("$.content[0].inputText").value("test text")); + } +} diff --git a/backend/src/test/java/com/truthlens/backend/controller/SystemHealthControllerTest.java b/backend/src/test/java/com/truthlens/backend/controller/SystemHealthControllerTest.java new file mode 100644 index 0000000..930bd41 --- /dev/null +++ b/backend/src/test/java/com/truthlens/backend/controller/SystemHealthControllerTest.java @@ -0,0 +1,57 @@ +package com.truthlens.backend.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.discovery.DiscoveryClient; +import org.springframework.test.web.servlet.MockMvc; +import com.truthlens.backend.config.SecurityConfig; +import org.springframework.context.annotation.Import; +import org.springframework.security.oauth2.jwt.JwtDecoder; + +import java.util.List; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(controllers = SystemHealthController.class, properties = {"spring.cloud.config.enabled=false", "eureka.client.enabled=false"}) +@Import(SecurityConfig.class) +class SystemHealthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private DiscoveryClient discoveryClient; + + @MockitoBean + private JwtDecoder jwtDecoder; + + @Test + void testGetHealth_AllUp() throws Exception { + when(discoveryClient.getInstances("truthlens-ocr-service")).thenReturn(List.of(mock(ServiceInstance.class))); + when(discoveryClient.getInstances("truthlens-search-service")).thenReturn(List.of(mock(ServiceInstance.class))); + + mockMvc.perform(get("/api/v1/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")) + .andExpect(jsonPath("$.ocr").value("UP")) + .andExpect(jsonPath("$.search").value("UP")); + } + + @Test + void testGetHealth_Down() throws Exception { + when(discoveryClient.getInstances("truthlens-ocr-service")).thenReturn(List.of()); + when(discoveryClient.getInstances("truthlens-search-service")).thenReturn(List.of(mock(ServiceInstance.class))); + + mockMvc.perform(get("/api/v1/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("DOWN")) + .andExpect(jsonPath("$.ocr").value("DOWN")) + .andExpect(jsonPath("$.search").value("UP")); + } +} diff --git a/backend/src/test/java/com/truthlens/backend/controller/UserControllerTest.java b/backend/src/test/java/com/truthlens/backend/controller/UserControllerTest.java new file mode 100644 index 0000000..7ae3dd9 --- /dev/null +++ b/backend/src/test/java/com/truthlens/backend/controller/UserControllerTest.java @@ -0,0 +1,78 @@ +package com.truthlens.backend.controller; + +import com.truthlens.backend.model.User; +import com.truthlens.backend.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import com.truthlens.backend.config.SecurityConfig; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(controllers = UserController.class, properties = {"spring.cloud.config.enabled=false", "eureka.client.enabled=false"}) +@Import(SecurityConfig.class) +class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private UserRepository userRepository; + + @MockitoBean + private JwtDecoder jwtDecoder; + + @Test + void testGetStatus() throws Exception { + mockMvc.perform(get("/api/public/status")) + .andExpect(status().isOk()) + .andExpect(content().string("TruthLens Backend is running and connected to DB (tested implicitly on startup)")); + } + + @Test + void testGetCurrentUser_ExistingUser() throws Exception { + User user = new User("sub-123", "test@example.com", "John", "Doe"); + when(userRepository.findByKeycloakId("sub-123")).thenReturn(Optional.of(user)); + + Jwt token = Jwt.withTokenValue("token") + .header("alg", "none") + .claim("sub", "sub-123") + .claim("email", "test@example.com") + .claim("given_name", "John") + .claim("family_name", "Doe") + .build(); + + mockMvc.perform(get("/api/users/me").with(jwt().jwt(token))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.keycloakId").value("sub-123")) + .andExpect(jsonPath("$.email").value("test@example.com")); + } + + @Test + void testGetCurrentUser_NewUser() throws Exception { + when(userRepository.findByKeycloakId("sub-new")).thenReturn(Optional.empty()); + when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArguments()[0]); + + Jwt token = Jwt.withTokenValue("token") + .header("alg", "none") + .claim("sub", "sub-new") + .claim("email", "new@example.com") + .build(); + + mockMvc.perform(get("/api/users/me").with(jwt().jwt(token))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.keycloakId").value("sub-new")) + .andExpect(jsonPath("$.email").value("new@example.com")); + } +} diff --git a/backend/src/test/java/com/truthlens/backend/controller/VerificationControllerTest.java b/backend/src/test/java/com/truthlens/backend/controller/VerificationControllerTest.java new file mode 100644 index 0000000..082c0e4 --- /dev/null +++ b/backend/src/test/java/com/truthlens/backend/controller/VerificationControllerTest.java @@ -0,0 +1,87 @@ +package com.truthlens.backend.controller; + +import com.truthlens.backend.model.VerificationResponse; +import com.truthlens.backend.model.VerificationStatus; +import com.truthlens.backend.service.VerificationOrchestratorService; +import com.truthlens.backend.service.VerificationStateService; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import com.truthlens.backend.config.SecurityConfig; +import org.springframework.context.annotation.Import; +import org.springframework.security.oauth2.jwt.JwtDecoder; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(controllers = VerificationController.class, properties = {"spring.cloud.config.enabled=false", "eureka.client.enabled=false"}) +@Import(SecurityConfig.class) +class VerificationControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private VerificationOrchestratorService orchestratorService; + + @MockitoBean + private VerificationStateService stateService; + + @MockitoBean + private JwtDecoder jwtDecoder; + + @Test + void testVerifyText() throws Exception { + VerificationResponse mockResponse = new VerificationResponse("test-id", VerificationStatus.QUEUED, 0, "Queued", null); + when(orchestratorService.startVerification(anyString(), any())).thenReturn(mockResponse); + + mockMvc.perform(post("/api/v1/verifications") + .with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"claimText\": \"test claim\"}")) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.verificationId").value("test-id")) + .andExpect(jsonPath("$.status").value("QUEUED")); + } + + @Test + void testVerifyImage() throws Exception { + VerificationResponse mockResponse = new VerificationResponse("test-id-img", VerificationStatus.QUEUED, 0, "Queued", null); + when(orchestratorService.startVerification(any(org.springframework.web.multipart.MultipartFile.class), any())).thenReturn(mockResponse); + + MockMultipartFile file = new MockMultipartFile("file", "test.png", "image/png", "test image content".getBytes()); + + mockMvc.perform(multipart("/api/v1/verifications") + .file(file) + .with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt())) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.verificationId").value("test-id-img")); + } + + @Test + void testGetStatus_Found() throws Exception { + VerificationResponse mockResponse = new VerificationResponse("test-id", VerificationStatus.COMPLETED, 100, "Done", null); + when(stateService.getState("test-id")).thenReturn(mockResponse); + + mockMvc.perform(get("/api/v1/verifications/test-id")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.verificationId").value("test-id")) + .andExpect(jsonPath("$.status").value("COMPLETED")); + } + + @Test + void testGetStatus_NotFound() throws Exception { + when(stateService.getState("unknown")).thenReturn(null); + + mockMvc.perform(get("/api/v1/verifications/unknown")) + .andExpect(status().isNotFound()); + } +} diff --git a/backend/src/test/java/com/truthlens/backend/model/ModelCoverageTest.java b/backend/src/test/java/com/truthlens/backend/model/ModelCoverageTest.java new file mode 100644 index 0000000..a75270d --- /dev/null +++ b/backend/src/test/java/com/truthlens/backend/model/ModelCoverageTest.java @@ -0,0 +1,51 @@ +package com.truthlens.backend.model; + +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class ModelCoverageTest { + + @Test + void testUserEntity() { + User user = new User("kc123", "test@example.com", "John", "Doe"); + user.setId(1L); + user.setKeycloakId("kc123"); + user.setEmail("test@example.com"); + user.setFirstName("John"); + user.setLastName("Doe"); + + assertThat(user.getId()).isEqualTo(1L); + assertThat(user.getKeycloakId()).isEqualTo("kc123"); + assertThat(user.getEmail()).isEqualTo("test@example.com"); + assertThat(user.getFirstName()).isEqualTo("John"); + assertThat(user.getLastName()).isEqualTo("Doe"); + assertThat(user.getCreatedAt()).isNull(); + } + + @Test + void testVerificationHistoryEntity() { + VerificationHistory history = new VerificationHistory("u1", "TEXT", "text", "file.png", "TRUE", 90.0, "reason"); + history.setId("h1"); + history.setUserId("u1"); + history.setInputType("TEXT"); + history.setInputText("text"); + history.setFileName("file.png"); + history.setFinalVerdict("TRUE"); + history.setAverageConfidence(90.0); + history.setAggregatedReasoning("reason"); + LocalDateTime now = LocalDateTime.now(); + history.setCreatedAt(now); + + assertThat(history.getId()).isEqualTo("h1"); + assertThat(history.getUserId()).isEqualTo("u1"); + assertThat(history.getInputType()).isEqualTo("TEXT"); + assertThat(history.getInputText()).isEqualTo("text"); + assertThat(history.getFileName()).isEqualTo("file.png"); + assertThat(history.getFinalVerdict()).isEqualTo("TRUE"); + assertThat(history.getAverageConfidence()).isEqualTo(90.0); + assertThat(history.getAggregatedReasoning()).isEqualTo("reason"); + assertThat(history.getCreatedAt()).isEqualTo(now); + } +} diff --git a/backend/src/test/java/com/truthlens/backend/service/GroqLlmServiceTest.java b/backend/src/test/java/com/truthlens/backend/service/GroqLlmServiceTest.java new file mode 100644 index 0000000..38a902c --- /dev/null +++ b/backend/src/test/java/com/truthlens/backend/service/GroqLlmServiceTest.java @@ -0,0 +1,65 @@ +package com.truthlens.backend.service; + +import com.truthlens.backend.client.GroqApiClient; +import com.truthlens.backend.config.GroqProperties; +import com.truthlens.backend.model.jury.GroqChatRequest; +import com.truthlens.backend.model.jury.GroqChatResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GroqLlmServiceTest { + + @Mock + private GroqApiClient groqApiClient; + + @Mock + private GroqProperties groqProperties; + + private GroqLlmService groqLlmService; + + @BeforeEach + void setUp() { + groqLlmService = new GroqLlmService(groqApiClient, groqProperties); + } + + @Test + void testAskModel_Success() { + when(groqProperties.apiKey()).thenReturn("test-api-key"); + + GroqChatResponse.Choice choice = new GroqChatResponse.Choice( + new com.truthlens.backend.model.jury.GroqMessage("assistant", "response content") + ); + + GroqChatResponse response = new GroqChatResponse(List.of(choice)); + + when(groqApiClient.getChatCompletion(eq("Bearer test-api-key"), any(GroqChatRequest.class))).thenReturn(response); + + String result = groqLlmService.askModel("llama3", "hello"); + + assertThat(result).isEqualTo("response content"); + } + + @Test + void testAskModel_EmptyChoices() { + when(groqProperties.apiKey()).thenReturn("test-api-key"); + + GroqChatResponse response = new GroqChatResponse(List.of()); + + when(groqApiClient.getChatCompletion(eq("Bearer test-api-key"), any(GroqChatRequest.class))).thenReturn(response); + + String result = groqLlmService.askModel("llama3", "hello"); + + assertThat(result).isNull(); + } +} diff --git a/backend/src/test/java/com/truthlens/backend/service/JuryVotingServiceTest.java b/backend/src/test/java/com/truthlens/backend/service/JuryVotingServiceTest.java new file mode 100644 index 0000000..9a05709 --- /dev/null +++ b/backend/src/test/java/com/truthlens/backend/service/JuryVotingServiceTest.java @@ -0,0 +1,96 @@ +package com.truthlens.backend.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.truthlens.backend.config.GroqProperties; +import com.truthlens.backend.model.ModelVoteResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JuryVotingServiceTest { + + @Mock + private GroqLlmService groqLlmService; + + @Mock + private GroqProperties groqProperties; + + private ObjectMapper objectMapper = new ObjectMapper(); + + private JuryVotingService juryVotingService; + + @BeforeEach + void setUp() { + juryVotingService = new JuryVotingService(groqLlmService, groqProperties, objectMapper); + } + + @Test + void testGatherVotes_Success() { + when(groqProperties.models()).thenReturn(List.of("model-1", "model-2")); + + String mockResponse1 = "{\"verdict\": 80, \"confidenceScore\": 90, \"reasoning\": \"Seems true\"}"; + String mockResponse2 = "{\"verdict\": 20, \"confidenceScore\": 85, \"reasoning\": \"Seems false\"}"; + + when(groqLlmService.askModel(eq("model-1"), anyString())).thenReturn(mockResponse1); + when(groqLlmService.askModel(eq("model-2"), anyString())).thenReturn(mockResponse2); + + List results = juryVotingService.gatherVotes("claim", "evidence"); + + assertThat(results).hasSize(2); + assertThat(results).extracting(ModelVoteResult::modelName).containsExactlyInAnyOrder("model-1", "model-2"); + + ModelVoteResult res1 = results.stream().filter(r -> r.modelName().equals("model-1")).findFirst().get(); + assertThat(res1.verdict()).isEqualTo("80"); + assertThat(res1.confidenceScore()).isEqualTo(90); + assertThat(res1.reasoning()).isEqualTo("Seems true"); + } + + @Test + void testGatherVotes_MalformedJson() { + when(groqProperties.models()).thenReturn(List.of("model-1")); + when(groqLlmService.askModel(eq("model-1"), anyString())).thenReturn("Not JSON"); + + List results = juryVotingService.gatherVotes("claim", "evidence"); + + assertThat(results).hasSize(1); + ModelVoteResult res = results.get(0); + assertThat(res.verdict()).isEqualTo("ERROR"); + assertThat(res.confidenceScore()).isEqualTo(0); + assertThat(res.reasoning()).contains("Failed to parse JSON"); + } + + @Test + void testGatherVotes_MissingFields() { + when(groqProperties.models()).thenReturn(List.of("model-1")); + when(groqLlmService.askModel(eq("model-1"), anyString())).thenReturn("{}"); + + List results = juryVotingService.gatherVotes("claim", "evidence"); + + assertThat(results).hasSize(1); + ModelVoteResult res = results.get(0); + assertThat(res.verdict()).isEqualTo("UNKNOWN"); + assertThat(res.confidenceScore()).isEqualTo(0); + assertThat(res.reasoning()).isEqualTo("{}"); + } + + @Test + void testGatherVotes_ThrowsException() { + when(groqProperties.models()).thenReturn(List.of("model-1")); + when(groqLlmService.askModel(eq("model-1"), anyString())).thenThrow(new RuntimeException("API down")); + + assertThatThrownBy(() -> juryVotingService.gatherVotes("claim", "evidence")) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Jury voting failed"); + } +} diff --git a/backend/src/test/java/com/truthlens/backend/service/VerificationHistoryServiceTest.java b/backend/src/test/java/com/truthlens/backend/service/VerificationHistoryServiceTest.java new file mode 100644 index 0000000..0fed591 --- /dev/null +++ b/backend/src/test/java/com/truthlens/backend/service/VerificationHistoryServiceTest.java @@ -0,0 +1,68 @@ +package com.truthlens.backend.service; + +import com.truthlens.backend.model.JuryReport; +import com.truthlens.backend.model.VerificationHistory; +import com.truthlens.backend.repository.VerificationHistoryRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class VerificationHistoryServiceTest { + + @Mock + private VerificationHistoryRepository historyRepository; + + private VerificationHistoryService historyService; + + @BeforeEach + void setUp() { + historyService = new VerificationHistoryService(historyRepository); + } + + @Test + void testSaveHistory() { + JuryReport report = new JuryReport("TRUE", 85.0, "Reasoning test"); + + historyService.saveHistory("user123", "TEXT", "claim text", null, report); + + ArgumentCaptor captor = ArgumentCaptor.forClass(VerificationHistory.class); + verify(historyRepository).save(captor.capture()); + + VerificationHistory saved = captor.getValue(); + assertThat(saved.getUserId()).isEqualTo("user123"); + assertThat(saved.getInputType()).isEqualTo("TEXT"); + assertThat(saved.getInputText()).isEqualTo("claim text"); + assertThat(saved.getFileName()).isNull(); + assertThat(saved.getFinalVerdict()).isEqualTo("TRUE"); + assertThat(saved.getAverageConfidence()).isEqualTo(85.0); + assertThat(saved.getAggregatedReasoning()).isEqualTo("Reasoning test"); + } + + @Test + void testGetHistory() { + Page page = new PageImpl<>(List.of(new VerificationHistory())); + Pageable pageable = Pageable.unpaged(); + + when(historyRepository.findByUserIdOrderByCreatedAtDesc(eq("user123"), eq(pageable))).thenReturn(page); + + Page result = historyService.getHistory("user123", pageable); + + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(1); + verify(historyRepository).findByUserIdOrderByCreatedAtDesc("user123", pageable); + } +} diff --git a/backend/src/test/java/com/truthlens/backend/service/VerificationOrchestratorServiceTest.java b/backend/src/test/java/com/truthlens/backend/service/VerificationOrchestratorServiceTest.java new file mode 100644 index 0000000..645b092 --- /dev/null +++ b/backend/src/test/java/com/truthlens/backend/service/VerificationOrchestratorServiceTest.java @@ -0,0 +1,151 @@ +package com.truthlens.backend.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.truthlens.backend.client.OcrServiceClient; +import com.truthlens.backend.client.SearchServiceClient; +import com.truthlens.backend.model.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class VerificationOrchestratorServiceTest { + + @Mock + private VerificationStateService stateService; + @Mock + private GroqLlmService groqLlmService; + @Mock + private JuryVotingService juryVotingService; + @Mock + private SearchServiceClient searchServiceClient; + @Mock + private OcrServiceClient ocrServiceClient; + @Mock + private VerificationHistoryService historyService; + + private ObjectMapper objectMapper = new ObjectMapper(); + + private VerificationOrchestratorService orchestratorService; + + @BeforeEach + void setUp() { + orchestratorService = new VerificationOrchestratorService( + stateService, groqLlmService, juryVotingService, searchServiceClient, ocrServiceClient, objectMapper, historyService); + } + + @Test + void startVerification_withText_shouldProcessSuccessfully() throws Exception { + String claimText = "Bill Gates adds chips to vaccines"; + String userId = "test-user-id"; + + when(groqLlmService.askModel(anyString(), anyString())) + .thenReturn("[\"vaccine chips fact check\", \"bill gates vaccines\"]"); + when(searchServiceClient.executeSearch(any())).thenReturn(new SearchExecutionResponse(List.of( + new SearchResult("Title", "http://example.com", "Snippet")))); + when(juryVotingService.gatherVotes(anyString(), anyString())).thenReturn(List.of( + new ModelVoteResult("Llama3", "FALSE", 99, "It is a conspiracy theory."))); + + VerificationResponse response = orchestratorService.startVerification(claimText, userId); + + assertThat(response).isNotNull(); + assertThat(response.status()).isEqualTo(VerificationStatus.QUEUED); + + verify(stateService, timeout(2000)).updateState(eq(response.verificationId()), eq(VerificationStatus.COMPLETED), + eq(100), anyString(), any(JuryReport.class)); + + verify(stateService).updateState(eq(response.verificationId()), eq(VerificationStatus.QUEUED), eq(0), + anyString(), isNull()); + verify(stateService).updateState(eq(response.verificationId()), eq(VerificationStatus.GENERATING_QUERIES), + eq(30)); + verify(stateService).updateState(eq(response.verificationId()), eq(VerificationStatus.SEARCHING_WEB), eq(50)); + verify(stateService).updateState(eq(response.verificationId()), eq(VerificationStatus.AI_JURY_VOTING), eq(70)); + verify(historyService, timeout(2000)).saveHistory(eq(userId), eq("TEXT"), eq(claimText), isNull(), any(JuryReport.class)); + } + + @Test + void startVerification_withImage_shouldProcessSuccessfully() throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "test.png", "image/png", "fake-image".getBytes()); + String extractedText = "Text extracted from image"; + String userId = "test-user-id"; + + when(ocrServiceClient.extractText(any())).thenReturn(new OcrExtractionResponse(extractedText, false, 95.0)); + when(groqLlmService.askModel(anyString(), anyString())).thenReturn("[\"extracted text check\"]"); + when(searchServiceClient.executeSearch(any())).thenReturn(new SearchExecutionResponse(List.of())); + when(juryVotingService.gatherVotes(anyString(), anyString())).thenReturn(List.of( + new ModelVoteResult("Llama3", "TRUE", 80, "Seems true."))); + + VerificationResponse response = orchestratorService.startVerification(file, userId); + + assertThat(response).isNotNull(); + assertThat(response.status()).isEqualTo(VerificationStatus.QUEUED); + + verify(stateService, timeout(2000)).updateState(eq(response.verificationId()), eq(VerificationStatus.COMPLETED), + eq(100), anyString(), any(JuryReport.class)); + + verify(stateService).updateState(eq(response.verificationId()), eq(VerificationStatus.OCR_PROCESSING), eq(10)); + verify(ocrServiceClient).extractText(any()); + verify(historyService, timeout(2000)).saveHistory(eq(userId), eq("IMAGE"), eq(extractedText), eq("test.png"), any(JuryReport.class)); + } + + @Test + void startVerification_withText_shouldHandleFailure() { + String claimText = "Error causing text"; + String userId = "test-user-id"; + when(groqLlmService.askModel(anyString(), anyString())).thenThrow(new RuntimeException("LLM API is down")); + + VerificationResponse response = orchestratorService.startVerification(claimText, userId); + verify(stateService, timeout(2000)).updateState(eq(response.verificationId()), eq(VerificationStatus.FAILED), + eq(0), contains("LLM API is down"), isNull()); + } + + @Test + void processVerification_withObjectArrayAndVariousQueries() throws Exception { + String claimText = "Another claim"; + String verificationId = "ver-id"; + + String complexJson = "{ \"queries\": [ {\"query\": \"q1\"}, {\"q\": \"q2\"}, {\"zapytanie\": \"q3\"}, \"{\\\"query\\\": \\\"q4\\\"}\", \"q5\" ] }"; + when(groqLlmService.askModel(anyString(), anyString())).thenReturn(complexJson); + + when(searchServiceClient.executeSearch(any())).thenReturn(new SearchExecutionResponse(List.of( + new SearchResult("Title", "http://example.com", "Snippet")))); + + when(juryVotingService.gatherVotes(anyString(), anyString())).thenReturn(List.of( + new ModelVoteResult("Llama3", "70", 90, "True reason"), + new ModelVoteResult("Mixtral", "40", 50, "False reason"), + new ModelVoteResult("Gemma", "FALSE", 80, "False reason") + )); + + orchestratorService.processVerification(verificationId, claimText, null, "TEXT", null); + + verify(stateService).updateState(eq(verificationId), eq(VerificationStatus.COMPLETED), + eq(100), anyString(), any(JuryReport.class)); + } + + @Test + void processVerification_withEmptyQueriesFallback() throws Exception { + String claimText = "Empty queries"; + String verificationId = "ver-id"; + + when(groqLlmService.askModel(anyString(), anyString())).thenReturn("[]"); + when(searchServiceClient.executeSearch(any())).thenReturn(new SearchExecutionResponse(List.of())); + when(juryVotingService.gatherVotes(anyString(), anyString())).thenReturn(List.of( + new ModelVoteResult("Llama3", "TRUE", 100, "reason") + )); + + orchestratorService.processVerification(verificationId, claimText, "user-id", "TEXT", null); + + verify(stateService).updateState(eq(verificationId), eq(VerificationStatus.COMPLETED), + eq(100), anyString(), any(JuryReport.class)); + verify(historyService).saveHistory(eq("user-id"), eq("TEXT"), eq(claimText), isNull(), any(JuryReport.class)); + } +} diff --git a/backend/src/test/java/com/truthlens/backend/service/VerificationStateServiceTest.java b/backend/src/test/java/com/truthlens/backend/service/VerificationStateServiceTest.java new file mode 100644 index 0000000..3f0fe35 --- /dev/null +++ b/backend/src/test/java/com/truthlens/backend/service/VerificationStateServiceTest.java @@ -0,0 +1,91 @@ +package com.truthlens.backend.service; + +import com.truthlens.backend.model.JuryReport; +import com.truthlens.backend.model.VerificationResponse; +import com.truthlens.backend.model.VerificationStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class VerificationStateServiceTest { + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + private VerificationStateService stateService; + + @BeforeEach + void setUp() { + stateService = new VerificationStateService(redisTemplate); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + } + + @Test + void testUpdateState_WithReport() { + JuryReport report = new JuryReport("TRUE", 100.0, "Test"); + + stateService.updateState("id123", VerificationStatus.COMPLETED, 100, "Done", report); + + ArgumentCaptor captor = ArgumentCaptor.forClass(VerificationResponse.class); + verify(valueOperations).set(eq("verification:id123"), captor.capture(), any(Duration.class)); + + VerificationResponse response = captor.getValue(); + assertThat(response.verificationId()).isEqualTo("id123"); + assertThat(response.status()).isEqualTo(VerificationStatus.COMPLETED); + assertThat(response.progressPercentage()).isEqualTo(100); + assertThat(response.message()).isEqualTo("Done"); + assertThat(response.result()).isEqualTo(report); + } + + @Test + void testUpdateState_WithoutReport() { + stateService.updateState("id123", VerificationStatus.OCR_PROCESSING, 50); + + ArgumentCaptor captor = ArgumentCaptor.forClass(VerificationResponse.class); + verify(valueOperations).set(eq("verification:id123"), captor.capture(), any(Duration.class)); + + VerificationResponse response = captor.getValue(); + assertThat(response.verificationId()).isEqualTo("id123"); + assertThat(response.status()).isEqualTo(VerificationStatus.OCR_PROCESSING); + assertThat(response.progressPercentage()).isEqualTo(50); + assertThat(response.message()).isNull(); + assertThat(response.result()).isNull(); + } + + @Test + void testGetState_Found() { + VerificationResponse mockResponse = new VerificationResponse("id123", VerificationStatus.COMPLETED, 100, "Done", null); + when(valueOperations.get("verification:id123")).thenReturn(mockResponse); + + VerificationResponse result = stateService.getState("id123"); + + assertThat(result).isNotNull(); + assertThat(result.verificationId()).isEqualTo("id123"); + } + + @Test + void testGetState_NotFound() { + when(valueOperations.get("verification:id123")).thenReturn(null); + + VerificationResponse result = stateService.getState("id123"); + + assertThat(result).isNull(); + } +} diff --git a/backend/src/test/java/com/truthlens/backend/steps/CucumberSpringConfiguration.java b/backend/src/test/java/com/truthlens/backend/steps/CucumberSpringConfiguration.java new file mode 100644 index 0000000..6b3f6db --- /dev/null +++ b/backend/src/test/java/com/truthlens/backend/steps/CucumberSpringConfiguration.java @@ -0,0 +1,107 @@ +package com.truthlens.backend.steps; + +import com.truthlens.backend.client.GroqApiClient; +import com.truthlens.backend.client.OcrServiceClient; +import com.truthlens.backend.client.SearchServiceClient; +import com.truthlens.backend.repository.VerificationHistoryRepository; +import io.cucumber.spring.CucumberContextConfiguration; +import org.mockito.Mockito; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.test.context.ActiveProfiles; + +import java.time.Duration; +import java.util.concurrent.ConcurrentHashMap; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.when; + +@CucumberContextConfiguration +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { + "spring.cloud.config.enabled=false", + "eureka.client.enabled=false", + "spring.autoconfigure.exclude=" + + "org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration," + + "org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.mongo.MongoRepositoriesAutoConfiguration," + + "org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration," + + "org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration", + "groq.api-url=http://localhost:9999", + "groq.api-key=test-key", + "groq.models=llama-3.1-8b-instant,llama-3.3-70b-versatile,gemma2-9b-it", + "spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:9999/realms/test", + "spring.main.allow-bean-definition-overriding=true" +}) +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(CucumberSpringConfiguration.InMemoryRedisConfig.class) +public class CucumberSpringConfiguration { + + @MockitoBean + JwtDecoder jwtDecoder; + + @MockitoBean + OcrServiceClient ocrServiceClient; + + @MockitoBean + SearchServiceClient searchServiceClient; + + @MockitoBean + GroqApiClient groqApiClient; + + @MockitoBean + VerificationHistoryRepository historyRepository; + + @MockitoBean + com.truthlens.backend.repository.UserRepository userRepository; + + @TestConfiguration + static class InMemoryRedisConfig { + + @Bean + @Primary + @SuppressWarnings("unchecked") + public RedisTemplate redisTemplate() { + ConcurrentHashMap store = new ConcurrentHashMap<>(); + + RedisTemplate mockTemplate = Mockito.mock(RedisTemplate.class); + ValueOperations mockValueOps = Mockito.mock(ValueOperations.class); + + when(mockTemplate.opsForValue()).thenReturn(mockValueOps); + + doAnswer(invocation -> { + String key = invocation.getArgument(0); + Object value = invocation.getArgument(1); + store.put(key, value); + return null; + }).when(mockValueOps).set(anyString(), any(), any(Duration.class)); + + doAnswer(invocation -> { + String key = invocation.getArgument(0); + Object value = invocation.getArgument(1); + store.put(key, value); + return null; + }).when(mockValueOps).set(anyString(), any()); + + when(mockValueOps.get(anyString())).thenAnswer(invocation -> { + String key = invocation.getArgument(0); + return store.get(key); + }); + + return mockTemplate; + } + } +} diff --git a/backend/src/test/java/com/truthlens/backend/steps/CucumberTestRunner.java b/backend/src/test/java/com/truthlens/backend/steps/CucumberTestRunner.java new file mode 100644 index 0000000..0ed2a03 --- /dev/null +++ b/backend/src/test/java/com/truthlens/backend/steps/CucumberTestRunner.java @@ -0,0 +1,17 @@ +package com.truthlens.backend.steps; + +import org.junit.platform.suite.api.ConfigurationParameter; +import org.junit.platform.suite.api.IncludeEngines; +import org.junit.platform.suite.api.SelectClasspathResource; +import org.junit.platform.suite.api.Suite; + +import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME; +import static io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME; + +@Suite +@IncludeEngines("cucumber") +@SelectClasspathResource("features") +@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "com.truthlens.backend.steps") +@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty, html:target/cucumber-reports/cucumber.html") +public class CucumberTestRunner { +} diff --git a/backend/src/test/java/com/truthlens/backend/steps/VerificationStepDefinitions.java b/backend/src/test/java/com/truthlens/backend/steps/VerificationStepDefinitions.java new file mode 100644 index 0000000..2cc4349 --- /dev/null +++ b/backend/src/test/java/com/truthlens/backend/steps/VerificationStepDefinitions.java @@ -0,0 +1,189 @@ +package com.truthlens.backend.steps; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.truthlens.backend.client.GroqApiClient; +import com.truthlens.backend.client.SearchServiceClient; +import com.truthlens.backend.model.*; +import com.truthlens.backend.model.jury.GroqChatResponse; +import com.truthlens.backend.model.jury.GroqMessage; +import io.cucumber.datatable.DataTable; +import io.cucumber.java.en.And; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public class VerificationStepDefinitions { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private SearchServiceClient searchServiceClient; + + @Autowired + private GroqApiClient groqApiClient; + + private List juryVoteStubs = new ArrayList<>(); + private MvcResult submitResult; + private JsonNode finalResponse; + private String verificationId; + private int submitStatusCode; + + private record JuryVoteStub(String model, String vote, int confidence) { + } + + @Given("the user is authenticated") + public void theUserIsAuthenticated() { + // puste + } + + @And("the external search service will return {int} web results contradicting the claim") + public void theExternalSearchServiceWillReturnWebResults(int resultCount) { + List searchResults = new ArrayList<>(); + for (int i = 1; i <= resultCount; i++) { + searchResults.add(new SearchResult( + "Contradicting Evidence " + i, + "https://science.example.com/evidence-" + i, + "Scientific evidence #" + i + " clearly contradicts the claim. " + + "Multiple peer-reviewed studies confirm this is false.")); + } + when(searchServiceClient.executeSearch(any(SearchExecutionRequest.class))) + .thenReturn(new SearchExecutionResponse(searchResults)); + } + + @And("the AI jury models will vote as follows:") + public void theAiJuryModelsWillVoteAsFollows(DataTable dataTable) { + List> rows = dataTable.asMaps(String.class, String.class); + juryVoteStubs.clear(); + for (Map row : rows) { + juryVoteStubs.add(new JuryVoteStub( + row.get("Model"), + row.get("Vote"), + Integer.parseInt(row.get("Confidence")))); + } + + String queryJson = "[\"flat earth debunked\", \"earth shape scientific evidence\", \"flat earth conspiracy\"]"; + GroqChatResponse queryResponse = buildGroqChatResponse(queryJson); + + List juryResponses = new ArrayList<>(); + for (JuryVoteStub stub : juryVoteStubs) { + int numericVerdict = "TRUE".equalsIgnoreCase(stub.vote()) ? 85 : 15; + String voteJson = """ + {"verdict": %d, "confidenceScore": %d, "reasoning": "%s model analysis: The claim has been evaluated against available evidence and determined to be %s with %d%% confidence."} + """ + .formatted(numericVerdict, stub.confidence(), stub.model(), stub.vote(), stub.confidence()); + juryResponses.add(buildGroqChatResponse(voteJson)); + } + + var stubbing = when(groqApiClient.getChatCompletion(anyString(), any())) + .thenReturn(queryResponse); + for (GroqChatResponse juryResponse : juryResponses) { + stubbing = stubbing.thenReturn(juryResponse); + } + } + + @When("the user submits a verification request with the text {string}") + public void theUserSubmitsAVerificationRequestWithText(String claimText) throws Exception { + String requestJson = objectMapper.writeValueAsString(new VerificationRequest(claimText)); + + submitResult = mockMvc.perform(post("/api/v1/verifications") + .with(jwt().jwt(builder -> builder + .subject("test-user-id") + .claim("preferred_username", "testuser") + .claim("email", "test@truthlens.com"))) + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andReturn(); + + submitStatusCode = submitResult.getResponse().getStatus(); + + JsonNode responseNode = objectMapper.readTree(submitResult.getResponse().getContentAsString()); + verificationId = responseNode.get("verificationId").asText(); + } + + @Then("the response status should be {int} ACCEPTED") + public void theResponseStatusShouldBeAccepted(int expectedStatus) { + assertThat(submitStatusCode) + .as("HTTP status code of the initial POST submission") + .isEqualTo(expectedStatus); + } + + @And("the verification status should eventually be {string}") + public void theVerificationStatusShouldEventuallyBe(String expectedStatus) { + await().atMost(10, TimeUnit.SECONDS) + .pollInterval(250, TimeUnit.MILLISECONDS) + .untilAsserted(() -> { + MvcResult pollResult = mockMvc.perform(get("/api/v1/verifications/" + verificationId)) + .andExpect(status().isOk()) + .andReturn(); + + JsonNode node = objectMapper.readTree(pollResult.getResponse().getContentAsString()); + String currentStatus = node.get("status").asText(); + assertThat(currentStatus) + .as("Verification status after async processing") + .isEqualTo(expectedStatus); + + finalResponse = node; + }); + } + + @And("the final verification verdict should be {string}") + public void theFinalVerificationVerdictShouldBe(String expectedVerdict) { + assertThat(finalResponse).as("Final response must be available (poll must have succeeded)").isNotNull(); + assertThat(finalResponse.has("result")).as("Response must contain a 'result' field").isTrue(); + + JsonNode result = finalResponse.get("result"); + assertThat(result.has("finalVerdict")).as("Result must contain 'finalVerdict'").isTrue(); + assertThat(result.get("finalVerdict").asText()) + .as("The AI jury's final verdict") + .isEqualTo(expectedVerdict); + } + + @And("the response should contain the reasoning from the AI models") + public void theResponseShouldContainTheReasoningFromTheAiModels() { + assertThat(finalResponse).as("Final response must be available").isNotNull(); + + JsonNode result = finalResponse.get("result"); + assertThat(result.has("aggregatedReasoning")).as("Result must contain 'aggregatedReasoning'").isTrue(); + + String reasoning = result.get("aggregatedReasoning").asText(); + assertThat(reasoning) + .as("Aggregated reasoning should not be empty") + .isNotBlank(); + + for (JuryVoteStub stub : juryVoteStubs) { + assertThat(reasoning) + .as("Reasoning should contain analysis from model: " + stub.model()) + .containsIgnoringCase(stub.model()); + } + } + + private GroqChatResponse buildGroqChatResponse(String content) { + GroqMessage message = new GroqMessage("assistant", content); + GroqChatResponse.Choice choice = new GroqChatResponse.Choice(message); + return new GroqChatResponse(List.of(choice)); + } +} diff --git a/backend/src/test/resources/features/verification.feature b/backend/src/test/resources/features/verification.feature new file mode 100644 index 0000000..13f6653 --- /dev/null +++ b/backend/src/test/resources/features/verification.feature @@ -0,0 +1,18 @@ +Feature: Fact-checking orchestration + The TruthLens backend processes verification requests by searching the web + for evidence, then letting an AI jury of multiple LLM models vote on the + truthfulness of the claim. + + Scenario: A user submits a claim as text and receives a FALSE verdict + Given the user is authenticated + And the external search service will return 3 web results contradicting the claim + And the AI jury models will vote as follows: + | Model | Vote | Confidence | + | Llama-8B | FALSE | 90 | + | Llama-70B | FALSE | 95 | + | Gemma | FALSE | 80 | + When the user submits a verification request with the text "The Earth is flat" + Then the response status should be 202 ACCEPTED + And the verification status should eventually be "COMPLETED" + And the final verification verdict should be "FALSE" + And the response should contain the reasoning from the AI models diff --git a/backend/src/test/resources/junit-platform.properties b/backend/src/test/resources/junit-platform.properties new file mode 100644 index 0000000..5e2e543 --- /dev/null +++ b/backend/src/test/resources/junit-platform.properties @@ -0,0 +1,4 @@ +cucumber.plugin=pretty, html:target/cucumber-reports/cucumber.html +cucumber.glue=com.truthlens.backend.steps +cucumber.features=classpath:features +cucumber.publish.quiet=true diff --git a/config-repo/truthlens-backend.yml b/config-repo/truthlens-backend.yml new file mode 100644 index 0000000..0ff4389 --- /dev/null +++ b/config-repo/truthlens-backend.yml @@ -0,0 +1,44 @@ +spring: + data: + redis: + host: ${REDIS_HOST:truthlens-redis} + port: ${REDIS_PORT:6379} + mongodb: + uri: mongodb://truthlens_app:truthlens_mongo_pass@${MONGO_HOST:truthlens-mongodb}:27017/truthlens_history?authSource=truthlens_history + datasource: + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + + security: + oauth2: + resourceserver: + jwt: + issuer-uri: ${KEYCLOAK_ISSUER_URI:http://localhost:8081/realms/truthlens} + jwk-set-uri: ${KEYCLOAK_JWKS_URI:http://truthlens-keycloak:8080/realms/truthlens/protocol/openid-connect/certs} + + threads: + virtual: + enabled: true + + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + +groq: + api-url: "https://api.groq.com/openai/v1" + api-key: ${GROQ_API_KEY} + models: + - "openai/gpt-oss-20b" + - "llama-3.3-70b-versatile" + - "qwen/qwen3-32b" \ No newline at end of file diff --git a/config-repo/truthlens-gateway.yml b/config-repo/truthlens-gateway.yml new file mode 100644 index 0000000..1facbd1 --- /dev/null +++ b/config-repo/truthlens-gateway.yml @@ -0,0 +1,39 @@ +server: + port: 8080 + +spring: + codec: + max-in-memory-size: 10MB + data: + redis: + host: ${REDIS_HOST:truthlens-redis} + port: ${REDIS_PORT:6379} + security: + oauth2: + resourceserver: + jwt: + issuer-uri: ${KEYCLOAK_ISSUER_URI:http://localhost:8081/realms/truthlens} + jwk-set-uri: ${KEYCLOAK_JWKS_URI:http://truthlens-keycloak:8080/realms/truthlens/protocol/openid-connect/certs} + +truthlens: + gateway: + cors: + allowed-origins: "http://localhost:3000" + rate-limit: + replenish-rate: 5 + burst-capacity: 10 + +logging: + level: + org.springframework.security: DEBUG + org.springframework.cloud.gateway: DEBUG + +springdoc: + swagger-ui: + urls: + - name: Backend API + url: /aggregate/backend/v3/api-docs + - name: OCR API + url: /aggregate/ocr/v3/api-docs + - name: Search API + url: /aggregate/search/v3/api-docs diff --git a/config-repo/truthlens-ocr-service.yml b/config-repo/truthlens-ocr-service.yml new file mode 100644 index 0000000..8e4a995 --- /dev/null +++ b/config-repo/truthlens-ocr-service.yml @@ -0,0 +1,20 @@ +spring: + threads: + virtual: + enabled: true + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + +server: + port: 0 + +gemini: + api: + key: ${GEMINI_API_KEY} + +eureka: + client: + serviceUrl: + defaultZone: http://truthlens-discovery-server:8761/eureka/ diff --git a/config-repo/truthlens-search-service.yml b/config-repo/truthlens-search-service.yml new file mode 100644 index 0000000..74ac34e --- /dev/null +++ b/config-repo/truthlens-search-service.yml @@ -0,0 +1,12 @@ +spring: + threads: + virtual: + enabled: true + +duckduckgo: + api-url: "https://api.duckduckgo.com" + +retry: + max-attempts: 3 + initial-delay: 1000 + multiplier: 2.0 diff --git a/config-server/.dockerignore b/config-server/.dockerignore new file mode 100644 index 0000000..ec10551 --- /dev/null +++ b/config-server/.dockerignore @@ -0,0 +1,3 @@ +target/ +.idea/ +*.iml diff --git a/config-server/.gitattributes b/config-server/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/config-server/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/config-server/.gitignore b/config-server/.gitignore new file mode 100644 index 0000000..667aaef --- /dev/null +++ b/config-server/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/config-server/.mvn/wrapper/maven-wrapper.properties b/config-server/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..5291372 --- /dev/null +++ b/config-server/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.15/apache-maven-3.9.15-bin.zip diff --git a/config-server/Dockerfile b/config-server/Dockerfile new file mode 100644 index 0000000..12a7c6c --- /dev/null +++ b/config-server/Dockerfile @@ -0,0 +1,13 @@ +FROM maven:3.9-eclipse-temurin-25-alpine AS build +WORKDIR /app +COPY pom.xml . +RUN mvn dependency:go-offline -B + +COPY src src +RUN mvn clean package -DskipTests + +FROM eclipse-temurin:25-jre-alpine +WORKDIR /app +COPY --from=build /app/target/*.jar app.jar +EXPOSE 8888 +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/config-server/mvnw b/config-server/mvnw new file mode 100644 index 0000000..bd8896b --- /dev/null +++ b/config-server/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/config-server/mvnw.cmd b/config-server/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/config-server/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/config-server/pom.xml b/config-server/pom.xml new file mode 100644 index 0000000..27151a9 --- /dev/null +++ b/config-server/pom.xml @@ -0,0 +1,85 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.11 + + + org.example + config-server + 0.0.1-SNAPSHOT + config-server + config-server + + + + + + + + + + + + + + + 25 + 2025.0.1 + + + + org.springframework.cloud + spring-cloud-config-server + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.jacoco + jacoco-maven-plugin + 0.8.14 + + + + prepare-agent + + + + report + test + + report + + + + + + + + diff --git a/config-server/src/main/java/org/example/configserver/ConfigServerApplication.java b/config-server/src/main/java/org/example/configserver/ConfigServerApplication.java new file mode 100644 index 0000000..482505e --- /dev/null +++ b/config-server/src/main/java/org/example/configserver/ConfigServerApplication.java @@ -0,0 +1,15 @@ +package org.example.configserver; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.config.server.EnableConfigServer; + +@SpringBootApplication +@EnableConfigServer +public class ConfigServerApplication { + + public static void main(String[] args) { + SpringApplication.run(ConfigServerApplication.class, args); + } + +} \ No newline at end of file diff --git a/config-server/src/main/resources/application.yaml b/config-server/src/main/resources/application.yaml new file mode 100644 index 0000000..5e36c46 --- /dev/null +++ b/config-server/src/main/resources/application.yaml @@ -0,0 +1,12 @@ +server: + port: 8888 +spring: + application: + name: config-server + profiles: + active: native + cloud: + config: + server: + native: + search-locations: file://${user.dir}/config-repo \ No newline at end of file diff --git a/config-server/src/test/java/org/example/configserver/ConfigServerApplicationTests.java b/config-server/src/test/java/org/example/configserver/ConfigServerApplicationTests.java new file mode 100644 index 0000000..73e1942 --- /dev/null +++ b/config-server/src/test/java/org/example/configserver/ConfigServerApplicationTests.java @@ -0,0 +1,19 @@ +package org.example.configserver; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ConfigServerApplicationTests { + + @Test + void contextLoads() { + Assertions.assertDoesNotThrow(() -> {}); + } + + @Test + void mainMethodLoads() { + Assertions.assertDoesNotThrow(() -> ConfigServerApplication.main(new String[] {"--server.port=0"})); + } +} diff --git a/discovery-server/.dockerignore b/discovery-server/.dockerignore new file mode 100644 index 0000000..ec10551 --- /dev/null +++ b/discovery-server/.dockerignore @@ -0,0 +1,3 @@ +target/ +.idea/ +*.iml diff --git a/discovery-server/.gitattributes b/discovery-server/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/discovery-server/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/discovery-server/.gitignore b/discovery-server/.gitignore new file mode 100644 index 0000000..667aaef --- /dev/null +++ b/discovery-server/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/discovery-server/.mvn/wrapper/maven-wrapper.properties b/discovery-server/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..5291372 --- /dev/null +++ b/discovery-server/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.15/apache-maven-3.9.15-bin.zip diff --git a/discovery-server/Dockerfile b/discovery-server/Dockerfile new file mode 100644 index 0000000..c0c057e --- /dev/null +++ b/discovery-server/Dockerfile @@ -0,0 +1,15 @@ +FROM maven:3.9-eclipse-temurin-25-alpine AS build +WORKDIR /app +# Kopiujemy pom.xml i pobieramy zależności, aby Docker je zapisał w cache +COPY pom.xml . +RUN mvn dependency:go-offline -B + +# Kopiujemy kod źródłowy i budujemy +COPY src src +RUN mvn clean package -DskipTests + +FROM eclipse-temurin:25-jre-alpine +WORKDIR /app +COPY --from=build /app/target/*.jar app.jar +EXPOSE 8761 +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/discovery-server/mvnw b/discovery-server/mvnw new file mode 100644 index 0000000..bd8896b --- /dev/null +++ b/discovery-server/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/discovery-server/mvnw.cmd b/discovery-server/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/discovery-server/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/discovery-server/pom.xml b/discovery-server/pom.xml new file mode 100644 index 0000000..3110237 --- /dev/null +++ b/discovery-server/pom.xml @@ -0,0 +1,85 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.11 + + + org.example + discovery-server + 0.0.1-SNAPSHOT + discovery-server + discovery-server + + + + + + + + + + + + + + + 25 + 2025.0.1 + + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-server + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.jacoco + jacoco-maven-plugin + 0.8.14 + + + + prepare-agent + + + + report + test + + report + + + + + + + + diff --git a/discovery-server/src/main/java/org/example/discoveryserver/DiscoveryServerApplication.java b/discovery-server/src/main/java/org/example/discoveryserver/DiscoveryServerApplication.java new file mode 100644 index 0000000..208cce1 --- /dev/null +++ b/discovery-server/src/main/java/org/example/discoveryserver/DiscoveryServerApplication.java @@ -0,0 +1,15 @@ +package org.example.discoveryserver; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer; + +@SpringBootApplication +@EnableEurekaServer +public class DiscoveryServerApplication { + + public static void main(String[] args) { + SpringApplication.run(DiscoveryServerApplication.class, args); + } + +} \ No newline at end of file diff --git a/discovery-server/src/main/resources/application.yaml b/discovery-server/src/main/resources/application.yaml new file mode 100644 index 0000000..d1a3818 --- /dev/null +++ b/discovery-server/src/main/resources/application.yaml @@ -0,0 +1,11 @@ +server: + port: 8761 +spring: + application: + name: discovery-server +eureka: + instance: + hostname: localhost + client: + register-with-eureka: false + fetch-registry: false \ No newline at end of file diff --git a/discovery-server/src/test/java/org/example/discoveryserver/DiscoveryServerApplicationTests.java b/discovery-server/src/test/java/org/example/discoveryserver/DiscoveryServerApplicationTests.java new file mode 100644 index 0000000..a79244a --- /dev/null +++ b/discovery-server/src/test/java/org/example/discoveryserver/DiscoveryServerApplicationTests.java @@ -0,0 +1,19 @@ +package org.example.discoveryserver; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class DiscoveryServerApplicationTests { + + @Test + void contextLoads() { + Assertions.assertDoesNotThrow(() -> {}); + } + + @Test + void mainMethodLoads() { + Assertions.assertDoesNotThrow(() -> DiscoveryServerApplication.main(new String[] {"--server.port=0"})); + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..51acc7f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,190 @@ +version: '3.8' + +services: + + config-server: + build: + context: ./config-server + container_name: truthlens-config-server + ports: + - "8888:8888" + volumes: + - ./config-repo:/config-repo + environment: + - SPRING_CLOUD_CONFIG_SERVER_NATIVE_SEARCH_LOCATIONS=file:///config-repo + + discovery-server: + build: + context: ./discovery-server + container_name: truthlens-discovery-server + ports: + - "8761:8761" + depends_on: + - config-server + + keycloak: + image: quay.io/keycloak/keycloak:24.0.3 + container_name: truthlens-keycloak + environment: + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://truthlens-postgres:5432/keycloak + KC_DB_USERNAME: keycloak + KC_DB_PASSWORD: password + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HOSTNAME_STRICT: "false" + KC_HTTP_ENABLED: "true" + KC_THEME_WELCOME_CACHE: "false" + KC_THEME_CACHE: "false" + command: start-dev --import-realm + ports: + - "8081:8080" + volumes: + - ./keycloak-theme/truthlens:/opt/keycloak/themes/truthlens + - ./keycloak-config:/opt/keycloak/data/import + depends_on: + - postgres + + postgres: + image: postgres:16 + container_name: truthlens-postgres + environment: + POSTGRES_DB: keycloak + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: password + ports: + - "5432:5432" + volumes: + - keycloak-data:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + container_name: truthlens-redis + ports: + - "6379:6379" + + mongodb: + image: mongo:7 + container_name: truthlens-mongodb + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: rootpassword + MONGO_INITDB_DATABASE: truthlens_history + ports: + - "27017:27017" + volumes: + - mongodb-data:/data/db + - ./mongo-init:/docker-entrypoint-initdb.d + + sonarqube: + image: sonarqube:10-community + container_name: truthlens-sonarqube + ports: + - "9000:9000" + environment: + SONAR_JDBC_URL: jdbc:postgresql://truthlens-sonar-db:5432/sonarqube + SONAR_JDBC_USERNAME: sonar + SONAR_JDBC_PASSWORD: sonar + volumes: + - sonarqube-data:/opt/sonarqube/data + - sonarqube-extensions:/opt/sonarqube/extensions + - sonarqube-logs:/opt/sonarqube/logs + depends_on: + - sonar-db + + sonar-db: + image: postgres:16 + container_name: truthlens-sonar-db + environment: + POSTGRES_DB: sonarqube + POSTGRES_USER: sonar + POSTGRES_PASSWORD: sonar + volumes: + - sonar-db-data:/var/lib/postgresql/data + + backend: + build: + context: ./backend + container_name: truthlens-backend + restart: on-failure + ports: + - "8083:8080" + env_file: + - ./.env + - ./backend/.env + extra_hosts: + - "localhost:host-gateway" + depends_on: + - keycloak + - postgres + - config-server + - discovery-server + - redis + - mongodb + + search-service: + build: + context: ./search-service + container_name: truthlens-search-service + restart: on-failure + ports: + - "8082:8080" + depends_on: + - config-server + - discovery-server + + gateway: + build: + context: ./gateway-service + container_name: truthlens-gateway + restart: on-failure + ports: + - "8080:8080" + environment: + - KEYCLOAK_ISSUER_URI=http://localhost:8081/realms/truthlens + extra_hosts: + - "localhost:host-gateway" + depends_on: + - keycloak + - config-server + - discovery-server + - redis + + frontend: + build: + context: ./frontend + container_name: truthlens-frontend + ports: + - "3000:3000" + env_file: + - ./frontend/.env.local + environment: + - AUTH_TRUST_HOST=true + - AUTH_URL=http://localhost:3000 + extra_hosts: + - "localhost:host-gateway" + depends_on: + - backend + + ocr-service: + build: + context: ./ocr-service + container_name: truthlens-ocr-service + restart: on-failure + ports: + - "8085:8080" + env_file: + - ./.env + - ./ocr-service/.env + depends_on: + - config-server + - discovery-server + +volumes: + keycloak-data: + name: backend_keycloak-data + mongodb-data: + sonarqube-data: + sonarqube-extensions: + sonarqube-logs: + sonar-db-data: diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..24c06b8 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..0b52979 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,16 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +COPY --from=builder /app/package*.json ./ +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public +COPY --from=builder /app/node_modules ./node_modules +EXPOSE 3000 +CMD ["npm", "run", "start"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000..199c0ec --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,16 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + globalIgnores([ + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000..db0a372 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,6 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { +}; + +export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..b9e1bca --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,6688 @@ +{ + "name": "temp-app", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "temp-app", + "version": "0.1.0", + "dependencies": { + "next": "16.2.6", + "next-auth": "^5.0.0-beta.31", + "react": "19.2.4", + "react-dom": "19.2.4" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.6", + "tailwindcss": "^4", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@auth/core": { + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.2.tgz", + "integrity": "sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^7.0.7" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz", + "integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.6.tgz", + "integrity": "sha512-Z8l6o4JWKUl755x4R+wogD86KPeU+Ckw4K+SYG4kHeOJtRenDeK+OSbGcqZpDtbwn9DsJVdir2UxmwXuinUbUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz", + "integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz", + "integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz", + "integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz", + "integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz", + "integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz", + "integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz", + "integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz", + "integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.4.tgz", + "integrity": "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "postcss": "^8.5.6", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.40", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.40.tgz", + "integrity": "sha512-xxx6M2IpSTnnKcR0cMvIiohkiCx20/oRPtWGbenFygKCGl3zqUzdNjQ/1V4solq1LU+dgv0nQzeGOuqkqZGg0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", + "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz", + "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", + "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", + "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.352", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.352.tgz", + "integrity": "sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.1.tgz", + "integrity": "sha512-8p7DUVq6XJnZEz9W4oSwiwycxBIjHjRzYb3Je3zVN+geKTRQKzAkR/K4PBExlS0090d9nshak6phMUxr3PDjmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.6.tgz", + "integrity": "sha512-z2ELYSkyrrJ6cuunTU8vhsT/RpouPkjaSah06nVW6Rg2Hpg0Vs8s497/e5s8G8qtdp4ccsiovz5P1rv+5VSW2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "16.2.6", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz", + "integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.6", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.6", + "@next/swc-darwin-x64": "16.2.6", + "@next/swc-linux-arm64-gnu": "16.2.6", + "@next/swc-linux-arm64-musl": "16.2.6", + "@next/swc-linux-x64-gnu": "16.2.6", + "@next/swc-linux-x64-musl": "16.2.6", + "@next/swc-win32-arm64-msvc": "16.2.6", + "@next/swc-win32-x64-msvc": "16.2.6", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next-auth": { + "version": "5.0.0-beta.31", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.31.tgz", + "integrity": "sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.2" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", + "nodemailer": "^7.0.7", + "react": "^18.2.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/oauth4webapi": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", + "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.2.tgz", + "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.2", + "@typescript-eslint/parser": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..415ae7f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "temp-app", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "next": "16.2.6", + "next-auth": "^5.0.0-beta.31", + "react": "19.2.4", + "react-dom": "19.2.4" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.6", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/frontend/public/bg.png b/frontend/public/bg.png new file mode 100644 index 0000000..8c36f2b Binary files /dev/null and b/frontend/public/bg.png differ diff --git a/frontend/public/file.svg b/frontend/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/frontend/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/frontend/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/next.svg b/frontend/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/frontend/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/frontend/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/window.svg b/frontend/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/frontend/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/app/analyze/AnalyzeClient.tsx b/frontend/src/app/analyze/AnalyzeClient.tsx new file mode 100644 index 0000000..b44c4e1 --- /dev/null +++ b/frontend/src/app/analyze/AnalyzeClient.tsx @@ -0,0 +1,314 @@ +"use client"; + +import React, { useState, useEffect, useRef } from 'react'; + +type VerificationStatus = 'IDLE' | 'QUEUED' | 'OCR_PROCESSING' | 'GENERATING_QUERIES' | 'SEARCHING_WEB' | 'AI_JURY_VOTING' | 'COMPLETED' | 'FAILED' | 'UPLOADING'; + +interface JuryReport { + finalVerdict: string; + averageConfidence: number; + aggregatedReasoning: string; +} + +interface VerificationResponse { + verificationId: string; + status: VerificationStatus; + progressPercentage: number; + message: string; + result?: JuryReport; +} + +interface AnalyzeClientProps { + token: string; +} + +export default function AnalyzeClient({ token }: AnalyzeClientProps) { + const [activeTab, setActiveTab] = useState<'screenshot' | 'text'>('screenshot'); + const [textInput, setTextInput] = useState(''); + const [selectedFile, setSelectedFile] = useState(null); + + const [verificationId, setVerificationId] = useState(null); + const [status, setStatus] = useState('IDLE'); + const [progress, setProgress] = useState(0); + const [message, setMessage] = useState(''); + const [result, setResult] = useState(null); + const [isReasoningExpanded, setIsReasoningExpanded] = useState(false); + const [lastAnalyzedContent, setLastAnalyzedContent] = useState(null); + + const [isSystemReady, setIsSystemReady] = useState(false); + + const pollingIntervalRef = useRef(null); + + const handleAnalyze = async () => { + if (activeTab === 'text' && !textInput.trim()) return; + if (activeTab === 'screenshot' && !selectedFile) return; + + setStatus('UPLOADING'); + setProgress(0); + setMessage('Sending request to server...'); + setResult(null); + setLastAnalyzedContent(activeTab === 'text' ? textInput : selectedFile!.name); + + try { + let response; + if (activeTab === 'text') { + const headers: Record = { + 'Content-Type': 'application/json' + }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + response = await fetch('http://127.0.0.1:8080/api/v1/verifications', { + method: 'POST', + headers, + body: JSON.stringify({ claimText: textInput }) + }); + } else { + const formData = new FormData(); + formData.append('file', selectedFile!); + const headers: Record = {}; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + response = await fetch('http://127.0.0.1:8080/api/v1/verifications', { + method: 'POST', + headers, + body: formData + }); + } + + if (response.ok) { + const data: VerificationResponse = await response.json(); + setVerificationId(data.verificationId); + setStatus(data.status); + setMessage(data.message || 'Queued...'); + } else { + setStatus('FAILED'); + setMessage('Server returned an error.'); + } + } catch (error) { + console.error(error); + setStatus('FAILED'); + setMessage('Failed to connect to the Gateway server on port 8080.'); + } + }; + + useEffect(() => { + if (!verificationId) return; + if (status === 'COMPLETED' || status === 'FAILED') return; + + const poll = async () => { + try { + const headers: Record = {}; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + const res = await fetch(`http://127.0.0.1:8080/api/v1/verifications/${verificationId}`, { + headers + }); + if (res.ok) { + const data: VerificationResponse = await res.json(); + setStatus(data.status); + setProgress(data.progressPercentage); + if (data.message) setMessage(data.message); + if (data.result) setResult(data.result); + } + } catch (error) { + console.error("Polling error", error); + } + }; + + pollingIntervalRef.current = setInterval(poll, 1500); + + return () => { + if (pollingIntervalRef.current) clearInterval(pollingIntervalRef.current); + }; + }, [verificationId, status, token]); + + useEffect(() => { + const checkHealth = async () => { + try { + const res = await fetch('http://127.0.0.1:8080/api/v1/health'); + if (res.ok) { + const data = await res.json(); + setIsSystemReady(data.status === 'UP'); + } else { + setIsSystemReady(false); + } + } catch (e) { + setIsSystemReady(false); + } + }; + + checkHealth(); + const interval = setInterval(checkHealth, 3000); + return () => clearInterval(interval); + }, []); + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + setSelectedFile(e.target.files[0]); + } + }; + + const isAnalyzing = status !== 'IDLE' && status !== 'COMPLETED' && status !== 'FAILED'; + + const currentContent = activeTab === 'text' ? textInput : selectedFile?.name || null; + const hasInput = currentContent !== null && currentContent.trim() !== ''; + const isInputChanged = lastAnalyzedContent !== currentContent; + const isReadyToAnalyze = hasInput && isInputChanged && !isAnalyzing && isSystemReady; + + return ( +
+ +
+

+ TruthLens Analysis +

+

+ Provide information to verify. +

+
+ +
+ + {status === 'COMPLETED' && result && ( +
+

+ {result.averageConfidence < 50 ? 'INCONCLUSIVE' : result.finalVerdict === 'TRUE' ? 'TRUE / FACT' : 'FALSE / FAKE NEWS'} +

+
+ Confidence Score: {result.averageConfidence.toFixed(1)}% +
+ +
+ +
+ + {isReasoningExpanded && ( +
+ {result.averageConfidence < 50 && ( +
+ Notice: The models voted for {result.finalVerdict === 'TRUE' ? 'TRUE' : 'FALSE'}, but they are not confident enough to make a definitive decision. +
+ )} + {result.aggregatedReasoning.split('\n').map((line, idx) => { + const colonIdx = line.indexOf(':'); + if (colonIdx !== -1) { + return ( +
+ AI {idx + 1}: {line.substring(colonIdx + 1).trim()} +
+ ); + } + return
{line}
; + })} +
+ )} +
+ )} + + {status === 'FAILED' && ( +
+

Verification Failed

+

{message}

+
+ )} + +
+ + +
+ +
+ {isAnalyzing ? ( +
+

{status.replace(/_/g, ' ')}

+

{message}

+ +
+
+
+

{progress}%

+
+ ) : activeTab === 'screenshot' ? ( +
+ +
📸
+

Upload a Screenshot

+

+ {selectedFile ? `Selected file: ${selectedFile.name}` : 'Drag and drop your screenshot here, or click to browse files.'} +

+
+ ) : ( +
+ +
+ )} +
+ +
+ +
+ +
+ +
+ ); +} diff --git a/frontend/src/app/analyze/page.tsx b/frontend/src/app/analyze/page.tsx new file mode 100644 index 0000000..8644b2b --- /dev/null +++ b/frontend/src/app/analyze/page.tsx @@ -0,0 +1,9 @@ +import { auth } from '@/auth'; +import AnalyzeClient from './AnalyzeClient'; + +export default async function AnalyzePage() { + const session = await auth(); + const token = (session as any)?.accessToken || ''; + + return ; +} diff --git a/frontend/src/app/api/auth/[...nextauth]/route.ts b/frontend/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..42e2953 --- /dev/null +++ b/frontend/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,2 @@ +import { handlers } from "@/auth" +export const { GET, POST } = handlers diff --git a/frontend/src/app/auth/page.tsx b/frontend/src/app/auth/page.tsx new file mode 100644 index 0000000..63a8294 --- /dev/null +++ b/frontend/src/app/auth/page.tsx @@ -0,0 +1,38 @@ +import MagnifyingGlassAuth from '@/components/MagnifyingGlassAuth'; +import { auth } from '@/auth'; +import { redirect } from 'next/navigation'; + +export default async function AuthPage() { + const session = await auth(); + + if (session) { + redirect('/dashboard'); + } + + return ( +
+ + + +
+

+ Access the TruthLens system. +
+ "You were built to protect those who cannot protect themselves." +

+
+
+ ); +} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx new file mode 100644 index 0000000..bcb720a --- /dev/null +++ b/frontend/src/app/dashboard/page.tsx @@ -0,0 +1,46 @@ +import { auth, signOut } from '@/auth'; +import { redirect } from 'next/navigation'; +import GlassTile from '@/components/GlassTile'; +import Link from 'next/link'; + +export default async function DashboardPage() { + const session = await auth(); + + if (!session) { + redirect('/auth'); + } + + return ( +
+
+

Your Dashboard

+

Select an action to start or view history.

+
+ +
+ + +
+
+ ); +} diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/frontend/src/app/favicon.ico differ diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..0b940e2 --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,286 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&family=Outfit:wght@300;400;600;800&display=swap'); + +:root { + --bg-color: #050505; + --text-color: #ffffff; + --neon-purple: #bc13fe; + --neon-yellow: #ffe135; + --neon-orange: #ff6700; + --glass-bg: rgba(0, 0, 0, 0.3); + --glass-border: rgba(255, 255, 255, 0.08); + --glass-blur: blur(35px); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background: var(--bg-color); + color: var(--text-color); + font-family: 'Outfit', sans-serif; + min-height: 100vh; + overflow-x: hidden; + position: relative; +} + +body::before { + content: ''; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: url('/bg.png') no-repeat center center; + background-size: cover; + z-index: -1; + opacity: 0.8; +} + + +.glass { + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--glass-border); + box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37); + border-radius: 16px; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.glass-tile:hover { + transform: translateY(-10px); + box-shadow: 0 15px 45px 0 rgba(0, 0, 0, 0.5); + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.15); +} + +.neon-border-purple { + border: 1px solid rgba(188, 19, 254, 0.3); + box-shadow: 0 0 15px rgba(188, 19, 254, 0.2); +} + +.neon-border-yellow { + border: 1px solid rgba(255, 225, 53, 0.3); + box-shadow: 0 0 15px rgba(255, 225, 53, 0.2); +} + +.neon-border-orange { + border: 1px solid rgba(255, 103, 0, 0.3); + box-shadow: 0 0 15px rgba(255, 103, 0, 0.2); +} + +.text-neon-purple { color: var(--neon-purple); text-shadow: 0 0 10px rgba(188, 19, 254, 0.5); } +.text-neon-yellow { color: var(--neon-yellow); text-shadow: 0 0 10px rgba(255, 225, 53, 0.5); } +.text-neon-orange { color: var(--neon-orange); text-shadow: 0 0 10px rgba(255, 103, 0, 0.5); } + + +.magnifier-container { + position: relative; + width: 380px; + height: 380px; + display: flex; + align-items: center; + justify-content: center; + margin: 2rem auto; +} + +.magnifier-lens { + width: 100%; + height: 100%; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + z-index: 2; + overflow: hidden; + background: rgba(255, 255, 255, 0.03); + backdrop-filter: blur(25px) saturate(180%); + animation: float 6s ease-in-out infinite; +} + +.magnifier-handle { + position: absolute; + width: 120px; + height: 30px; + background: var(--glass-bg); + backdrop-filter: blur(10px); + bottom: -30px; + right: -50px; + transform: rotate(45deg); + border-radius: 0 10px 10px 0; + z-index: 1; +} + +.auth-form { + width: 100%; + max-width: 300px; +} + +.input-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.input-group label { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 1px; + opacity: 0.8; +} + +.input-group input { + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--glass-border); + padding: 0.8rem 1rem; + border-radius: 8px; + color: white; + outline: none; + transition: border-color 0.3s; +} + +.input-group input:focus { + border-color: var(--neon-orange); +} + +.auth-button { + background: rgba(255, 103, 0, 0.1); + color: white; + padding: 1rem; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s; + margin-top: 0.5rem; +} + +.auth-button:hover { + background: rgba(255, 103, 0, 0.2); + transform: scale(1.02); +} + +@keyframes float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-20px); } +} + +@media (max-width: 600px) { + .magnifier-container { + width: 350px; + height: 350px; + } + .magnifier-handle { + display: none; + } +} + +a { + color: inherit; + text-decoration: none; +} + +h1, h2, h3 { + font-weight: 800; + letter-spacing: -0.02em; +} + + +.tab-container { + display: flex; + gap: 1rem; + margin-bottom: 2rem; + justify-content: center; +} + +.tab-button { + background: transparent; + border: 1px solid var(--glass-border); + color: white; + padding: 0.8rem 2rem; + border-radius: 8px; + cursor: pointer; + font-family: inherit; + font-weight: 600; + transition: all 0.3s; + opacity: 0.7; +} + +.tab-button:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.05); +} + +.tab-button.active { + opacity: 1; + border-color: var(--neon-purple); + box-shadow: 0 0 15px rgba(188, 19, 254, 0.2); + background: rgba(188, 19, 254, 0.1); +} + +.dropzone { + border: 2px dashed var(--glass-border); + border-radius: 16px; + padding: 4rem 2rem; + text-align: center; + cursor: pointer; + transition: all 0.3s; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +.dropzone:hover { + border-color: var(--neon-purple); + background: rgba(188, 19, 254, 0.05); +} + +.analyze-textarea { + width: 100%; + min-height: 250px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--glass-border); + border-radius: 16px; + padding: 1.5rem; + color: white; + font-family: inherit; + font-size: 1rem; + resize: vertical; + outline: none; + transition: all 0.3s; +} + +.analyze-textarea:focus { + border-color: var(--neon-purple); + box-shadow: 0 0 15px rgba(188, 19, 254, 0.2); + background: rgba(255, 255, 255, 0.05); +} + +@keyframes pulse-glow { + 0% { + box-shadow: 0 0 10px rgba(188, 19, 254, 0.4); + border-color: rgba(188, 19, 254, 0.5); + } + 50% { + box-shadow: 0 0 30px rgba(188, 19, 254, 0.9), 0 0 10px rgba(188, 19, 254, 0.5) inset; + border-color: rgba(188, 19, 254, 1); + } + 100% { + box-shadow: 0 0 10px rgba(188, 19, 254, 0.4); + border-color: rgba(188, 19, 254, 0.5); + } +} + +.glow-active { + animation: pulse-glow 2s infinite ease-in-out; +} + +@keyframes pulse-glow-red { + 0% { box-shadow: 0 0 5px #ff0000; opacity: 1; } + 50% { box-shadow: 0 0 20px #ff0000; opacity: 0.4; } + 100% { box-shadow: 0 0 5px #ff0000; opacity: 1; } +} diff --git a/frontend/src/app/history/HistoryClient.tsx b/frontend/src/app/history/HistoryClient.tsx new file mode 100644 index 0000000..f7d0378 --- /dev/null +++ b/frontend/src/app/history/HistoryClient.tsx @@ -0,0 +1,486 @@ +"use client"; + +import React, { useState, useEffect } from 'react'; +import Link from 'next/link'; + +interface VerificationHistory { + id: string; + userId: string; + inputType: 'TEXT' | 'IMAGE'; + inputText: string; + fileName?: string; + finalVerdict: 'TRUE' | 'FALSE'; + averageConfidence: number; + aggregatedReasoning: string; + createdAt: string; +} + +interface PageResponse { + content: VerificationHistory[]; + pageable: { + pageNumber: number; + pageSize: number; + }; + totalPages: number; + totalElements: number; + last: boolean; + first: boolean; + size: number; + number: number; +} + +interface HistoryClientProps { + token: string; +} + +export default function HistoryClient({ token }: HistoryClientProps) { + const [historyPage, setHistoryPage] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [sortDirection, setSortDirection] = useState<'desc' | 'asc'>('desc'); + const [expandedItems, setExpandedItems] = useState>({}); + + const fetchHistory = async (page: number, direction: 'desc' | 'asc') => { + setLoading(true); + setError(null); + try { + const res = await fetch(`http://127.0.0.1:8080/api/v1/history?page=${page}&size=10&sort=createdAt,${direction}`, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + if (res.ok) { + const data: PageResponse = await res.json(); + setHistoryPage(data); + } else { + setError('Failed to fetch verification history from server.'); + } + } catch (err) { + console.error(err); + setError('Could not connect to Gateway service.'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchHistory(currentPage, sortDirection); + }, [currentPage, sortDirection, token]); + + const toggleExpand = (id: string) => { + setExpandedItems(prev => ({ + ...prev, + [id]: !prev[id] + })); + }; + + const handleSortToggle = () => { + const nextDirection = sortDirection === 'desc' ? 'asc' : 'desc'; + setSortDirection(nextDirection); + setCurrentPage(0); + }; + + const formatDate = (dateString: string) => { + try { + const date = new Date(dateString); + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } catch (e) { + return dateString; + } + }; + + return ( +
+
+

+ Verification History +

+

+ Browse your past analyzed contents and AI Jury reports. +

+
+ + +
+ + Back to Dashboard + + + {historyPage && historyPage.totalElements > 0 && ( + + )} +
+ + {loading ? ( +
+ {[1, 2, 3].map(i => ( +
+
+
+
+ ))} +
+ ) : error ? ( +
+

Failed to Load History

+

{error}

+ +
+ ) : !historyPage || historyPage.content.length === 0 ? ( +
+
📚
+

No History Found

+

You haven't run any content verifications yet.

+ + Analyze Your First Content + +
+ ) : ( + <> +
+ {historyPage.content.map((item) => { + const isExpanded = !!expandedItems[item.id]; + const isImage = item.inputType === 'IMAGE'; + + return ( +
+ +
+
+
+ {isImage ? '📸' : '📝'} + + {isImage ? 'Image File' : 'Text Input'} + + {isImage && item.fileName && ( + + {item.fileName} + + )} +
+ + {formatDate(item.createdAt)} + +
+ + +
+
+
+ {item.averageConfidence < 50 ? 'INCONCLUSIVE' : item.finalVerdict === 'TRUE' ? 'TRUE' : 'FALSE'} +
+
+ Confidence: {item.averageConfidence.toFixed(1)}% +
+
+
+
+ + +
+

+ {item.inputText} + {!isExpanded && item.inputText.length > 250 && ( + + )} +

+
+ + +
+ + + {item.inputText.length > 250 && !isExpanded && ( + + )} +
+ + + {isExpanded && ( +
+ {item.averageConfidence < 50 && ( +
+ Notice: The models voted for {item.finalVerdict === 'TRUE' ? 'TRUE' : 'FALSE'}, but they are not confident enough to make a definitive decision. +
+ )} +

+ AI JURY DETAILED REASONING +

+ + {!item.aggregatedReasoning || item.aggregatedReasoning.trim() === "" ? ( +

+ No detailed reasoning provided by the AI Jury. +

+ ) : ( +
+ {item.aggregatedReasoning.split('\n').map((line, idx) => { + const colonIdx = line.indexOf(':'); + if (colonIdx !== -1) { + const modelName = line.substring(0, colonIdx).trim(); + const reasoning = line.substring(colonIdx + 1).trim(); + return ( +
+ {modelName}: +
+ {reasoning} +
+
+ ); + } + return
{line}
; + })} +
+ )} +
+ )} +
+ ); + })} +
+ + + {historyPage.totalPages > 1 && ( +
+ + + + Page {historyPage.number + 1} of {historyPage.totalPages} + + + +
+ )} + + )} + + + +
+ ); +} diff --git a/frontend/src/app/history/page.tsx b/frontend/src/app/history/page.tsx new file mode 100644 index 0000000..909e530 --- /dev/null +++ b/frontend/src/app/history/page.tsx @@ -0,0 +1,16 @@ +import { auth } from '@/auth'; +import { redirect } from 'next/navigation'; +import HistoryClient from './HistoryClient'; + +export default async function HistoryPage() { + const session = await auth(); + + if (!session) { + redirect('/auth'); + } + + const token = (session as any)?.accessToken || ''; + + return ; +} + diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..3ec47f3 --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import Header from "@/components/Header"; + +export const metadata: Metadata = { + title: "TruthLens | AI Fact-Checking", + description: "Automated verification of internet content truthfulness based on screenshots.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + +
+ {children} + + + ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000..1fd3bd8 --- /dev/null +++ b/frontend/src/app/page.tsx @@ -0,0 +1,65 @@ +import GlassTile from '@/components/GlassTile'; +import Link from 'next/link'; +import { auth } from '@/auth'; +import { redirect } from 'next/navigation'; + +export default async function Home() { + const session = await auth(); + + if (session) { + redirect('/dashboard'); + } + + return ( +
+
+

+ TruthLens +

+

+ AI - powered truth verification system. +

+
+ +
+ + +
+ +
+ "We cannot change the past, but we can improve for the future." ~ Sensei Wu +
+ TruthLens. +
+
+ ); +} diff --git a/frontend/src/auth.ts b/frontend/src/auth.ts new file mode 100644 index 0000000..298889b --- /dev/null +++ b/frontend/src/auth.ts @@ -0,0 +1,24 @@ +import NextAuth from "next-auth" +import KeycloakProvider from "next-auth/providers/keycloak" + +export const { handlers, auth, signIn, signOut } = NextAuth({ + providers: [ + KeycloakProvider({ + clientId: process.env.AUTH_KEYCLOAK_ID, + clientSecret: process.env.AUTH_KEYCLOAK_SECRET, + issuer: process.env.AUTH_KEYCLOAK_ISSUER, + }), + ], + callbacks: { + async jwt({ token, account }) { + if (account) { + token.accessToken = account.access_token; + } + return token; + }, + async session({ session, token }) { + (session as any).accessToken = token.accessToken; + return session; + } + } +}) diff --git a/frontend/src/components/GlassTile.tsx b/frontend/src/components/GlassTile.tsx new file mode 100644 index 0000000..90b6750 --- /dev/null +++ b/frontend/src/components/GlassTile.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import Link from 'next/link'; + +interface GlassTileProps { + title: string; + description: string; + icon?: string; + color?: 'purple' | 'yellow' | 'orange'; + href?: string; + actionText?: string; +} + +const GlassTile: React.FC = ({ title, description, icon, color = 'purple', href, actionText = 'Learn More' }) => { + const neonClass = `neon-border-${color}`; + const textNeonClass = `text-neon-${color}`; + + const tileContent = ( +
+
{icon || '✨'}
+

{title}

+

{description}

+ +
+ + {actionText} + +
+
+ ); + + return href ? ( + + {tileContent} + + ) : tileContent; +}; + +export default GlassTile; diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 0000000..769c24c --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -0,0 +1,70 @@ +import { auth, signOut } from '@/auth'; +import Link from 'next/link'; + +export default async function Header() { + const session = await auth(); + + return ( +
+ +

+ TruthLens +

+ + + {session && ( + <> + + +
+ + Welcome, {session.user?.name || session.user?.email || 'Użytkowniku'} + +
{ + "use server" + await signOut({ redirectTo: '/' }); + }}> + +
+
+ + )} + {!session && ( +
+ Log In +
+ )} +
+ ); +} diff --git a/frontend/src/components/MagnifyingGlassAuth.tsx b/frontend/src/components/MagnifyingGlassAuth.tsx new file mode 100644 index 0000000..a3b8cad --- /dev/null +++ b/frontend/src/components/MagnifyingGlassAuth.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +const MagnifyingGlassAuth = () => { + return ( +
+
+
+

Keycloak Auth

+
{ + "use server" + const { signIn } = await import("@/auth"); + await signIn("keycloak", { redirectTo: "/dashboard" }); + }} + style={{ display: 'flex', flexDirection: 'column', gap: '1.2rem', width: '100%' }} + > +

+ Click below to authenticate via Keycloak. +

+ +
+
+
+
+
+ ); +}; + +export default MagnifyingGlassAuth; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..295acdb --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./src/*" + ] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts", + ".next/dev/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/gateway-service/Dockerfile b/gateway-service/Dockerfile new file mode 100644 index 0000000..f968113 --- /dev/null +++ b/gateway-service/Dockerfile @@ -0,0 +1,13 @@ +FROM maven:3.9-eclipse-temurin-25-alpine AS build +WORKDIR /app +COPY pom.xml . +RUN mvn dependency:go-offline -B + +COPY src src +RUN mvn clean package -DskipTests + +FROM eclipse-temurin:25-jre-alpine +WORKDIR /app +COPY --from=build /app/target/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/gateway-service/pom.xml b/gateway-service/pom.xml new file mode 100644 index 0000000..129944c --- /dev/null +++ b/gateway-service/pom.xml @@ -0,0 +1,157 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.11 + + + + com.truthlens + gateway-service + 0.0.1-SNAPSHOT + TruthLens API Gateway + Spring Cloud Gateway — single public entry point for TruthLens + + + 25 + 2025.0.1 + + + + + + org.springframework.cloud + spring-cloud-starter-gateway + + + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-client + + + + + org.springdoc + springdoc-openapi-starter-webflux-ui + 2.8.5 + + + + + org.springframework.boot + spring-boot-starter-data-redis-reactive + + + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + + + org.springframework.cloud + spring-cloud-starter-config + + + + org.springframework.boot + spring-boot-starter-test + test + + + io.projectreactor + reactor-test + test + + + + + io.cucumber + cucumber-java + 7.18.0 + test + + + io.cucumber + cucumber-spring + 7.18.0 + test + + + io.cucumber + cucumber-junit-platform-engine + 7.18.0 + test + + + org.junit.platform + junit-platform-suite + test + + + + + org.testcontainers + junit-jupiter + test + + + org.springframework.boot + spring-boot-testcontainers + test + + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.14 + + + + prepare-agent + + + + report + verify + + report + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/gateway-service/src/main/java/com/truthlens/gateway/GatewayServiceApplication.java b/gateway-service/src/main/java/com/truthlens/gateway/GatewayServiceApplication.java new file mode 100644 index 0000000..3cdbe93 --- /dev/null +++ b/gateway-service/src/main/java/com/truthlens/gateway/GatewayServiceApplication.java @@ -0,0 +1,14 @@ +package com.truthlens.gateway; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; + +@SpringBootApplication +@EnableDiscoveryClient +public class GatewayServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(GatewayServiceApplication.class, args); + } +} diff --git a/gateway-service/src/main/java/com/truthlens/gateway/config/GatewayRoutesConfig.java b/gateway-service/src/main/java/com/truthlens/gateway/config/GatewayRoutesConfig.java new file mode 100644 index 0000000..5be1c7c --- /dev/null +++ b/gateway-service/src/main/java/com/truthlens/gateway/config/GatewayRoutesConfig.java @@ -0,0 +1,109 @@ +package com.truthlens.gateway.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver; +import org.springframework.cloud.gateway.filter.ratelimit.RedisRateLimiter; +import org.springframework.cloud.gateway.route.RouteLocator; +import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.security.Principal; + +@Configuration +public class GatewayRoutesConfig { + + private static final String SEGMENT = "/${segment}"; + + @Value("${truthlens.gateway.rate-limit.replenish-rate:5}") + private int replenishRate; + + @Value("${truthlens.gateway.rate-limit.burst-capacity:10}") + private int burstCapacity; + + @Bean + public RedisRateLimiter redisRateLimiter() { + return new RedisRateLimiter(replenishRate, burstCapacity); + } + + @Bean + public KeyResolver smartKeyResolver() { + return exchange -> { + String authHeader = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + return exchange.getPrincipal() + .map(Principal::getName) + .onErrorResume(e -> Mono.just(fallbackKey(exchange))); + } + return Mono.just(fallbackKey(exchange)); + }; + } + + private String fallbackKey(ServerWebExchange exchange) { + var remoteAddress = exchange.getRequest().getRemoteAddress(); + return (remoteAddress != null && remoteAddress.getAddress() != null) + ? remoteAddress.getAddress().getHostAddress() + : "anonymous"; + } + + @Bean + public RouteLocator customRouteLocator(RouteLocatorBuilder builder, RedisRateLimiter redisRateLimiter, + KeyResolver smartKeyResolver) { + return builder.routes() + .route("backend_verifications_route", r -> r + .path("/api/v1/verifications/**") + .filters(f -> f + .tokenRelay() + .requestRateLimiter(c -> c + .setRateLimiter(redisRateLimiter) + .setKeyResolver(smartKeyResolver))) + .uri("lb://truthlens-backend")) + .route("backend_history_route", r -> r + .path("/api/v1/history/**") + .filters(f -> f + .tokenRelay() + .requestRateLimiter(c -> c + .setRateLimiter(redisRateLimiter) + .setKeyResolver(smartKeyResolver))) + .uri("lb://truthlens-backend")) + .route("backend_health_route", r -> r + .path("/api/v1/health") + .uri("lb://truthlens-backend")) + .route("backend_users_route", r -> r + .path("/api/users/**") + .filters(f -> f + .tokenRelay() + .requestRateLimiter(c -> c + .setRateLimiter(redisRateLimiter) + .setKeyResolver(smartKeyResolver))) + .uri("lb://truthlens-backend")) + .route("keycloak_auth_route", r -> r + .path("/api/auth/**") + .filters(f -> f.rewritePath("/api/auth/?(?.*)", SEGMENT)) + .uri("http://truthlens-keycloak:8080")) + .route("search_internal_route", r -> r + .path("/api/internal/v1/search**", "/api/internal/v1/search/**") + .uri("lb://truthlens-search-service")) + .route("ocr_internal_route", r -> r + .path("/api/internal/v1/ocr**", "/api/internal/v1/ocr/**") + .uri("lb://truthlens-ocr-service")) + .route("openapi_backend", r -> r + .path("/aggregate/backend/v3/api-docs") + .filters(f -> f.rewritePath("/aggregate/backend/(?.*)", + SEGMENT)) + .uri("lb://truthlens-backend")) + .route("openapi_ocr", r -> r + .path("/aggregate/ocr/v3/api-docs") + .filters(f -> f.rewritePath("/aggregate/ocr/(?.*)", SEGMENT)) + .uri("lb://truthlens-ocr-service")) + .route("openapi_search", r -> r + .path("/aggregate/search/v3/api-docs") + .filters(f -> f.rewritePath("/aggregate/search/(?.*)", + SEGMENT)) + .uri("lb://truthlens-search-service")) + .build(); + } +} diff --git a/gateway-service/src/main/java/com/truthlens/gateway/config/GatewaySecurityConfig.java b/gateway-service/src/main/java/com/truthlens/gateway/config/GatewaySecurityConfig.java new file mode 100644 index 0000000..58d07eb --- /dev/null +++ b/gateway-service/src/main/java/com/truthlens/gateway/config/GatewaySecurityConfig.java @@ -0,0 +1,54 @@ +package com.truthlens.gateway.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.reactive.CorsConfigurationSource; +import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@EnableWebFluxSecurity +public class GatewaySecurityConfig { + + @Value("${truthlens.gateway.cors.allowed-origins:http://localhost:3000}") + private String allowedOrigins; + + @Bean + public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { + http + .cors(Customizer.withDefaults()) + .csrf(ServerHttpSecurity.CsrfSpec::disable) + .authorizeExchange(exchanges -> exchanges + .pathMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .pathMatchers("/api/auth/**").permitAll() + .pathMatchers("/actuator/health", "/api/v1/health", "/api/v1/verifications/**").permitAll() + .pathMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/webjars/**", "/aggregate/**").permitAll() + .pathMatchers("/api/internal/**").permitAll() + .anyExchange().authenticated()) + .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration corsConfig = new CorsConfiguration(); + corsConfig.setAllowedOrigins(List.of(allowedOrigins.split(","))); + corsConfig.addAllowedMethod("*"); + corsConfig.addAllowedHeader("*"); + corsConfig.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", corsConfig); + + return source; + } +} + diff --git a/gateway-service/src/main/resources/application.yml b/gateway-service/src/main/resources/application.yml new file mode 100644 index 0000000..6ebd5c9 --- /dev/null +++ b/gateway-service/src/main/resources/application.yml @@ -0,0 +1,18 @@ +spring: + application: + name: truthlens-gateway + config: + import: "optional:configserver:http://truthlens-config-server:8888" + cloud: + config: + fail-fast: true + retry: + initial-interval: 2000 + max-interval: 10000 + multiplier: 1.5 + max-attempts: 15 + +eureka: + client: + serviceUrl: + defaultZone: http://truthlens-discovery-server:8761/eureka/ diff --git a/gateway-service/src/test/java/com/truthlens/gateway/AbstractIntegrationTest.java b/gateway-service/src/test/java/com/truthlens/gateway/AbstractIntegrationTest.java new file mode 100644 index 0000000..5171eef --- /dev/null +++ b/gateway-service/src/test/java/com/truthlens/gateway/AbstractIntegrationTest.java @@ -0,0 +1,24 @@ +package com.truthlens.gateway; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Testcontainers +public abstract class AbstractIntegrationTest { + + @Container + @SuppressWarnings("resource") + static GenericContainer redis = new GenericContainer<>("redis:7-alpine").withExposedPorts(6379); + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + redis.start(); + registry.add("spring.data.redis.host", redis::getHost); + registry.add("spring.data.redis.port", redis::getFirstMappedPort); + } +} diff --git a/gateway-service/src/test/java/com/truthlens/gateway/CucumberTestRunner.java b/gateway-service/src/test/java/com/truthlens/gateway/CucumberTestRunner.java new file mode 100644 index 0000000..f00b817 --- /dev/null +++ b/gateway-service/src/test/java/com/truthlens/gateway/CucumberTestRunner.java @@ -0,0 +1,15 @@ +package com.truthlens.gateway; + +import org.junit.platform.suite.api.ConfigurationParameter; +import org.junit.platform.suite.api.IncludeEngines; +import org.junit.platform.suite.api.SelectClasspathResource; +import org.junit.platform.suite.api.Suite; + +import static io.cucumber.core.options.Constants.GLUE_PROPERTY_NAME; + +@Suite +@IncludeEngines("cucumber") +@SelectClasspathResource("features") +@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "com.truthlens.gateway.steps") +public class CucumberTestRunner { +} \ No newline at end of file diff --git a/gateway-service/src/test/java/com/truthlens/gateway/GatewayApplicationTest.java b/gateway-service/src/test/java/com/truthlens/gateway/GatewayApplicationTest.java new file mode 100644 index 0000000..b6d7b97 --- /dev/null +++ b/gateway-service/src/test/java/com/truthlens/gateway/GatewayApplicationTest.java @@ -0,0 +1,21 @@ +package com.truthlens.gateway; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(properties = { + "spring.cloud.discovery.enabled=false" +}) +class GatewayApplicationTest { + + @Test + void contextLoads() { + Assertions.assertDoesNotThrow(() -> {}); + } + + @Test + void mainMethodLoads() { + Assertions.assertDoesNotThrow(() -> GatewayServiceApplication.main(new String[] { "--server.port=0", "--spring.cloud.discovery.enabled=false" })); + } +} diff --git a/gateway-service/src/test/java/com/truthlens/gateway/config/ConfigCoverageTest.java b/gateway-service/src/test/java/com/truthlens/gateway/config/ConfigCoverageTest.java new file mode 100644 index 0000000..4507ee0 --- /dev/null +++ b/gateway-service/src/test/java/com/truthlens/gateway/config/ConfigCoverageTest.java @@ -0,0 +1,145 @@ +package com.truthlens.gateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver; +import org.springframework.cloud.gateway.filter.ratelimit.RedisRateLimiter; +import org.springframework.cloud.gateway.route.RouteLocator; +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.web.cors.reactive.CorsConfigurationSource; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.net.InetSocketAddress; +import java.security.Principal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@SpringBootTest(properties = { + "truthlens.gateway.cors.allowed-origins=http://localhost:3000", + "truthlens.gateway.rate-limit.replenish-rate=5", + "truthlens.gateway.rate-limit.burst-capacity=10", + "spring.cloud.gateway.enabled=true" +}) +class ConfigCoverageTest { + + @Autowired + private ApplicationContext context; + + @Autowired + private RedisRateLimiter redisRateLimiter; + + @Autowired + private KeyResolver smartKeyResolver; + + @Autowired + private RouteLocator customRouteLocator; + + @Autowired + private CorsConfigurationSource corsConfigurationSource; + + @Test + void testRedisRateLimiterConfigured() { + assertThat(redisRateLimiter).isNotNull(); + } + + @Test + void testSmartKeyResolver_withValidBearerTokenAndPrincipal() { + MockServerHttpRequest request = MockServerHttpRequest.get("/") + .header(HttpHeaders.AUTHORIZATION, "Bearer some-token") + .build(); + + Principal principal = () -> "testUser"; + + MockServerWebExchange exchange = MockServerWebExchange.builder(request) + .principal(principal) + .build(); + + Mono keyMono = smartKeyResolver.resolve(exchange); + StepVerifier.create(keyMono) + .expectNext("testUser") + .verifyComplete(); + } + + @Test + void testSmartKeyResolver_withNoToken() { + MockServerHttpRequest request = MockServerHttpRequest.get("/") + .remoteAddress(new InetSocketAddress("127.0.0.1", 8080)) + .build(); + + MockServerWebExchange exchange = MockServerWebExchange.from(request); + + Mono keyMono = smartKeyResolver.resolve(exchange); + StepVerifier.create(keyMono) + .expectNext("127.0.0.1") + .verifyComplete(); + } + + @Test + void testSmartKeyResolver_withNoRemoteAddress() { + MockServerHttpRequest request = MockServerHttpRequest.get("/") + .build(); + + MockServerWebExchange exchange = MockServerWebExchange.from(request); + + Mono keyMono = smartKeyResolver.resolve(exchange); + StepVerifier.create(keyMono) + .expectNext("anonymous") + .verifyComplete(); + } + + @Test + void testSmartKeyResolver_withTokenButErrorInPrincipal() { + MockServerHttpRequest request = MockServerHttpRequest.get("/") + .header(HttpHeaders.AUTHORIZATION, "Bearer some-token") + .remoteAddress(new InetSocketAddress("10.0.0.1", 8080)) + .build(); + + ServerWebExchangeMock exchange = new ServerWebExchangeMock(request); + + Mono keyMono = smartKeyResolver.resolve(exchange.getExchange()); + StepVerifier.create(keyMono) + .expectNext("10.0.0.1") + .verifyComplete(); + } + + @Test + void testCustomRouteLocatorConfigured() { + assertThat(customRouteLocator).isNotNull(); + assertThat(customRouteLocator.getRoutes().collectList().block()).isNotEmpty(); + } + + @Autowired + private SecurityWebFilterChain springSecurityFilterChain; + + @Test + void testSecurityConfig() { + assertThat(springSecurityFilterChain).isNotNull(); + } + + @Test + void testCorsConfigurationSource() { + assertThat(corsConfigurationSource).isNotNull(); + } + + private static class ServerWebExchangeMock { + private final MockServerWebExchange exchange; + + ServerWebExchangeMock(MockServerHttpRequest request) { + this.exchange = mock(MockServerWebExchange.class); + when(exchange.getRequest()).thenReturn(request); + when(exchange.getPrincipal()).thenReturn(Mono.error(new RuntimeException("Principal error"))); + } + + MockServerWebExchange getExchange() { + return exchange; + } + } +} diff --git a/gateway-service/src/test/java/com/truthlens/gateway/steps/CucumberSpringConfiguration.java b/gateway-service/src/test/java/com/truthlens/gateway/steps/CucumberSpringConfiguration.java new file mode 100644 index 0000000..23dd76c --- /dev/null +++ b/gateway-service/src/test/java/com/truthlens/gateway/steps/CucumberSpringConfiguration.java @@ -0,0 +1,8 @@ +package com.truthlens.gateway.steps; + +import io.cucumber.spring.CucumberContextConfiguration; +import com.truthlens.gateway.AbstractIntegrationTest; + +@CucumberContextConfiguration +public class CucumberSpringConfiguration extends AbstractIntegrationTest { +} diff --git a/gateway-service/src/test/java/com/truthlens/gateway/steps/GatewayStepDefinitions.java b/gateway-service/src/test/java/com/truthlens/gateway/steps/GatewayStepDefinitions.java new file mode 100644 index 0000000..a1948d2 --- /dev/null +++ b/gateway-service/src/test/java/com/truthlens/gateway/steps/GatewayStepDefinitions.java @@ -0,0 +1,107 @@ +package com.truthlens.gateway.steps; + +import io.cucumber.java.en.And; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import org.springframework.beans.factory.annotation.Autowired; + +import org.springframework.test.web.reactive.server.WebTestClient; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; + +public class GatewayStepDefinitions { + + @Autowired + private WebTestClient webTestClient; + + private WebTestClient.ResponseSpec lastResponse; + private final List collectedStatuses = new ArrayList<>(); + + @Given("an anonymous user") + public void an_anonymous_user() { + collectedStatuses.clear(); + } + + @Given("an anonymous user with a unique IP {string}") + public void an_anonymous_user_with_unique_ip(String ip) { + collectedStatuses.clear(); + } + + @Given("a preflight OPTIONS request to {string} from {string}") + public void a_preflight_options_request(String path, String origin) { + collectedStatuses.clear(); + lastResponse = webTestClient.options() + .uri(Objects.requireNonNull(path)) + .header("Origin", Objects.requireNonNull(origin)) + .header("Access-Control-Request-Method", "GET") + .exchange(); + } + + @When("the user sends a GET request to {string}") + public void the_user_sends_single_request(String path) { + lastResponse = webTestClient.get() + .uri(Objects.requireNonNull(path)) + .exchange(); + } + + @When("the user sends {int} sequential requests to {string}") + public void the_user_sends_sequential_requests(int count, String path) { + collectedStatuses.clear(); + for (int i = 0; i < count; i++) { + int status = webTestClient.get() + .uri(Objects.requireNonNull(path)) + .exchange() + .returnResult(String.class) + .getStatus() + .value(); + collectedStatuses.add(status); + } + } + + @When("the request is processed") + public void the_request_is_processed() { + // puste + } + + @Then("the response should NOT be {int} Unauthorized") + public void the_response_should_not_be_unauthorized(int status) { + int actualStatus = lastResponse.returnResult(String.class).getStatus().value(); + assertThat(actualStatus).isNotEqualTo(status); + } + + @Then("at least one response should have status {int} Too Many Requests") + public void at_least_one_response_should_be_429(int expectedStatus) { + assertThat(collectedStatuses) + .as("Expected at least one HTTP %d among responses: %s", expectedStatus, collectedStatuses) + .contains(expectedStatus); + } + + @Then("it should return {int} OK") + public void it_should_return_status(int status) { + lastResponse.expectStatus().isEqualTo(status); + } + + @And("the response should contain header {string} with value {string}") + public void the_response_should_contain_header(String header, String value) { + lastResponse.expectHeader().valueEquals( + Objects.requireNonNull(header), + Objects.requireNonNull(value)); + } + + @Then("the response should NOT contain header {string} with value {string}") + public void the_response_should_not_contain_header_with_value(String header, String value) { + var headers = lastResponse.returnResult(String.class).getResponseHeaders(); + var headerValues = headers.get(Objects.requireNonNull(header)); + + if (headerValues != null) { + assertThat(headerValues) + .as("Header '%s' should not contain forbidden value '%s'", header, value) + .doesNotContain(Objects.requireNonNull(value)); + } + } +} diff --git a/gateway-service/src/test/resources/application.yml b/gateway-service/src/test/resources/application.yml new file mode 100644 index 0000000..43c9a8b --- /dev/null +++ b/gateway-service/src/test/resources/application.yml @@ -0,0 +1,30 @@ +spring: + application: + name: truthlens-gateway-test + cloud: + config: + enabled: false + main: + web-application-type: reactive + + security: + oauth2: + resourceserver: + jwt: + jwk-set-uri: "http://localhost:9999/realms/test/protocol/openid-connect/certs" + +eureka: + client: + enabled: false + +spring.cloud.discovery.client.simple.instances: + truthlens-backend: + - uri: "http://localhost:9998" + +truthlens: + gateway: + cors: + allowed-origins: "http://localhost:3000" + rate-limit: + replenish-rate: 1 + burst-capacity: 2 diff --git a/gateway-service/src/test/resources/features/gateway.feature b/gateway-service/src/test/resources/features/gateway.feature new file mode 100644 index 0000000..63d844d --- /dev/null +++ b/gateway-service/src/test/resources/features/gateway.feature @@ -0,0 +1,22 @@ +Feature: API Gateway Security and Routing + + Scenario: Anonymous user can access the verifications endpoint + Given an anonymous user + When the user sends a GET request to "/api/v1/verifications" + Then the response should NOT be 401 Unauthorized + + Scenario: Rate limiting blocks requests from anonymous user exceeding burst capacity + Given an anonymous user with a unique IP "10.0.0.1" + When the user sends 5 sequential requests to "/api/v1/verifications" + Then at least one response should have status 429 Too Many Requests + + Scenario: Valid CORS preflight request is accepted + Given a preflight OPTIONS request to "/api/v1/verifications" from "http://localhost:3000" + When the request is processed + Then it should return 200 OK + And the response should contain header "Access-Control-Allow-Origin" with value "http://localhost:3000" + + Scenario: CORS preflight from unauthorized origin is rejected + Given a preflight OPTIONS request to "/api/v1/verifications" from "http://evil.com" + When the request is processed + Then the response should NOT contain header "Access-Control-Allow-Origin" with value "http://evil.com" diff --git a/intro-intellij/grupy/README.md b/intro-intellij/grupy/README.md index e69de29..dbcd5b3 100644 --- a/intro-intellij/grupy/README.md +++ b/intro-intellij/grupy/README.md @@ -0,0 +1,6 @@ +Nazwa grupy: switch_case +Członkowie: +251656 +251652 +251490 +251611 diff --git a/keycloak-config/realm-export.json b/keycloak-config/realm-export.json new file mode 100644 index 0000000..afb4e36 --- /dev/null +++ b/keycloak-config/realm-export.json @@ -0,0 +1,2315 @@ +{ + "id": "3bd1860f-c694-498e-9b6a-5f87d8c9353a", + "realm": "truthlens", + "displayName": "", + "displayNameHtml": "", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": true, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxTemporaryLockouts": 0, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "roles": { + "realm": [ + { + "id": "feecf5f0-1e84-4651-9def-897f09d18fdf", + "name": "default-roles-truthlens", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": [ + "offline_access", + "uma_authorization" + ], + "client": { + "account": [ + "view-profile", + "manage-account" + ] + } + }, + "clientRole": false, + "containerId": "3bd1860f-c694-498e-9b6a-5f87d8c9353a", + "attributes": {} + }, + { + "id": "8b4007f7-ff7f-46df-ba2a-3de907482432", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "3bd1860f-c694-498e-9b6a-5f87d8c9353a", + "attributes": {} + }, + { + "id": "14c9e6a1-907b-4128-89d9-001b02e73a83", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "3bd1860f-c694-498e-9b6a-5f87d8c9353a", + "attributes": {} + } + ], + "client": { + "realm-management": [ + { + "id": "308a1e08-084c-4d61-a9a5-e7a14b5b0076", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "f0092ee8-5b59-4fc6-b93e-c87c06a13fc6", + "attributes": {} + }, + { + "id": "2a870129-c9fe-4435-8ef8-c05da0b15ecb", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "f0092ee8-5b59-4fc6-b93e-c87c06a13fc6", + "attributes": {} + }, + { + "id": "f627147c-5ec8-42c5-9c3b-afb3eefb8b50", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "f0092ee8-5b59-4fc6-b93e-c87c06a13fc6", + "attributes": {} + }, + { + "id": "07606551-1b72-4218-bf2f-323b85110d17", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-groups", + "query-users" + ] + } + }, + "clientRole": true, + "containerId": "f0092ee8-5b59-4fc6-b93e-c87c06a13fc6", + "attributes": {} + }, + { + "id": "4211b3b6-7a2d-4e19-b6da-257df87784b0", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "f0092ee8-5b59-4fc6-b93e-c87c06a13fc6", + "attributes": {} + }, + { + "id": "58d9012f-55bb-418c-9b67-f1cd2c1af582", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "f0092ee8-5b59-4fc6-b93e-c87c06a13fc6", + "attributes": {} + }, + { + "id": "93f896de-75e5-4257-9bc0-7ca2dc2cf992", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "f0092ee8-5b59-4fc6-b93e-c87c06a13fc6", + "attributes": {} + }, + { + "id": "d82bb054-4575-4415-ab74-927d734ab4b8", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "f0092ee8-5b59-4fc6-b93e-c87c06a13fc6", + "attributes": {} + }, + { + "id": "777d6328-b91e-4602-ad8d-498bd01effe4", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "f0092ee8-5b59-4fc6-b93e-c87c06a13fc6", + "attributes": {} + }, + { + "id": "56e45a8a-8a48-44e6-be80-e1a4ac5afccb", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "f0092ee8-5b59-4fc6-b93e-c87c06a13fc6", + "attributes": {} + }, + { + "id": "d7d4670c-7241-420d-aac0-de9b02d89e96", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-clients" + ] + } + }, + "clientRole": true, + "containerId": "f0092ee8-5b59-4fc6-b93e-c87c06a13fc6", + "attributes": {} + }, + { + "id": "382a1151-2e2b-40bf-8ac2-fca96d3faf17", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "f0092ee8-5b59-4fc6-b93e-c87c06a13fc6", + "attributes": {} + }, + { + "id": "c00c0100-c284-4ec7-8d89-07d17fca37f0", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "manage-realm", + "manage-events", + "view-realm", + "view-users", + "create-client", + "query-clients", + "impersonation", + "query-groups", + "manage-clients", + "query-users", + "view-clients", + "manage-identity-providers", + "manage-authorization", + "view-events", + "view-identity-providers", + "view-authorization", + "manage-users", + "query-realms" + ] + } + }, + "clientRole": true, + "containerId": "f0092ee8-5b59-4fc6-b93e-c87c06a13fc6", + "attributes": {} + }, + { + "id": "5612101e-7b10-4ced-9c8d-cbb8e948b62e", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "f0092ee8-5b59-4fc6-b93e-c87c06a13fc6", + "attributes": {} + }, + { + "id": "ce67461a-5b14-472c-8f0c-3500ad0dccc5", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "f0092ee8-5b59-4fc6-b93e-c87c06a13fc6", + "attributes": {} + }, + { + "id": "d1112af9-61b1-403f-ac7b-2477edf661b2", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "f0092ee8-5b59-4fc6-b93e-c87c06a13fc6", + "attributes": {} + }, + { + "id": "37106b26-8f5e-4609-8c9e-b241dd548119", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "f0092ee8-5b59-4fc6-b93e-c87c06a13fc6", + "attributes": {} + }, + { + "id": "84e8746d-4efe-4826-91a1-883a865347de", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "f0092ee8-5b59-4fc6-b93e-c87c06a13fc6", + "attributes": {} + }, + { + "id": "e36dfdd0-fc8b-49d1-9fa5-3fbe2c2a089c", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "f0092ee8-5b59-4fc6-b93e-c87c06a13fc6", + "attributes": {} + } + ], + "security-admin-console": [], + "admin-cli": [], + "account-console": [], + "truthlens-client": [], + "broker": [ + { + "id": "ee224b85-95f5-4694-b5f4-1b5802f55ac4", + "name": "read-token", + "description": "${role_read-token}", + "composite": false, + "clientRole": true, + "containerId": "4678dd1a-9287-4163-a7b3-8902e71b2c90", + "attributes": {} + } + ], + "account": [ + { + "id": "884c3a04-0da9-4757-b290-cf7e1f8cd4db", + "name": "manage-consent", + "description": "${role_manage-consent}", + "composite": true, + "composites": { + "client": { + "account": [ + "view-consent" + ] + } + }, + "clientRole": true, + "containerId": "e61ef869-63f4-450f-bd4e-35dbf91203b5", + "attributes": {} + }, + { + "id": "98246c5a-6997-4de5-9db8-691b352e4ace", + "name": "view-applications", + "description": "${role_view-applications}", + "composite": false, + "clientRole": true, + "containerId": "e61ef869-63f4-450f-bd4e-35dbf91203b5", + "attributes": {} + }, + { + "id": "5eb9ddf8-6914-41b4-938e-63ebe8911eac", + "name": "view-groups", + "description": "${role_view-groups}", + "composite": false, + "clientRole": true, + "containerId": "e61ef869-63f4-450f-bd4e-35dbf91203b5", + "attributes": {} + }, + { + "id": "c5031689-b1c5-49c4-943b-4b967aa0c84b", + "name": "view-profile", + "description": "${role_view-profile}", + "composite": false, + "clientRole": true, + "containerId": "e61ef869-63f4-450f-bd4e-35dbf91203b5", + "attributes": {} + }, + { + "id": "e85482a8-1095-495a-afc7-26dd0879b6b1", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true, + "containerId": "e61ef869-63f4-450f-bd4e-35dbf91203b5", + "attributes": {} + }, + { + "id": "034eb5ac-40a3-4c06-a31d-3bb59acde4cd", + "name": "view-consent", + "description": "${role_view-consent}", + "composite": false, + "clientRole": true, + "containerId": "e61ef869-63f4-450f-bd4e-35dbf91203b5", + "attributes": {} + }, + { + "id": "bc436479-8958-4074-abc4-b5858cc27eee", + "name": "manage-account", + "description": "${role_manage-account}", + "composite": true, + "composites": { + "client": { + "account": [ + "manage-account-links" + ] + } + }, + "clientRole": true, + "containerId": "e61ef869-63f4-450f-bd4e-35dbf91203b5", + "attributes": {} + }, + { + "id": "8023cdfe-568a-4c50-936f-ea6be50e40e5", + "name": "manage-account-links", + "description": "${role_manage-account-links}", + "composite": false, + "clientRole": true, + "containerId": "e61ef869-63f4-450f-bd4e-35dbf91203b5", + "attributes": {} + } + ] + } + }, + "groups": [], + "defaultRole": { + "id": "feecf5f0-1e84-4651-9def-897f09d18fdf", + "name": "default-roles-truthlens", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "containerId": "3bd1860f-c694-498e-9b6a-5f87d8c9353a" + }, + "requiredCredentials": [ + "password" + ], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpPolicyCodeReusable": false, + "otpSupportedApplications": [ + "totpAppFreeOTPName", + "totpAppGoogleName", + "totpAppMicrosoftAuthenticatorName" + ], + "localizationTexts": {}, + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyExtraOrigins": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "webAuthnPolicyPasswordlessExtraOrigins": [], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": [ + "offline_access" + ] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": [ + "manage-account", + "view-groups" + ] + } + ] + }, + "clients": [ + { + "id": "e61ef869-63f4-450f-bd4e-35dbf91203b5", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/truthlens/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/truthlens/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "623aa4a4-31ba-4f66-925c-466f51f774e2", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/truthlens/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/truthlens/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "962c0092-fd83-47a6-a7d2-226b471e7ddc", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "0dfcf95f-3abc-463a-b9ba-3554c71d96bb", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "4678dd1a-9287-4163-a7b3-8902e71b2c90", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "f0092ee8-5b59-4fc6-b93e-c87c06a13fc6", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "25475f7b-38e7-41bc-8faf-6b9ecb6ef568", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/truthlens/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/admin/truthlens/console/*" + ], + "webOrigins": [ + "+" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "9ae7c2f0-e9bd-44a3-b6bb-66086ae6a667", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "d533072c-015a-4b1b-91bb-ec0de4e6b90d", + "clientId": "truthlens-client", + "name": "Lloyd Garmadon", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "cAuSfwzYv6YdmNTn1LS8cy84AavtiXgv", + "redirectUris": [ + "http://localhost:3000/*" + ], + "webOrigins": [ + "http://localhost:3000" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "oauth2.device.authorization.grant.enabled": "false", + "client.secret.creation.time": "1778230277", + "backchannel.logout.session.required": "true", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + } + ], + "clientScopes": [ + { + "id": "7239d5eb-5744-4e73-9fd7-892458d31b06", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${profileScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "79948fe7-7c37-4895-b77d-265ffaa44da2", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "0b40757f-a2a1-45f9-ad81-44264e327a83", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "fdfbe68b-8c82-4216-ad48-f10819bf3e77", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "a85dbbb9-b04a-4975-a6b4-d2613643915a", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "557c3582-4ff3-4c61-bdd5-812d52fbd142", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "02bbc2d3-5e82-469a-bd41-902ded588099", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "6539e1eb-2c4f-400a-ab75-0a738976708d", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "3a8a026b-2956-43fe-998b-43e336fe6973", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "54cc7c7f-b042-4403-a606-d6890cfbced7", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "aab72387-b82a-4069-8cfe-955756c646b7", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "2deff3f0-2e89-4ef7-bdd8-fe41dab13f3c", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "a704fa78-9e5c-43c8-8e95-b18373df51a1", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "9410df83-b46f-4fdd-bc51-9fa834cc74ee", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "61543103-cab8-43e8-b5ec-95b55726e45b", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + } + ] + }, + { + "id": "5a26e684-d2af-42c7-8dd5-53b807f5208c", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "6bc01d64-af69-452f-b2d5-b2bba667a478", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "09abaa0e-6628-4b82-b96e-41fd3078d57c", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "947a3e62-f317-4d13-a3ed-bc1a9f116921", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "f115513d-00d7-461e-9634-097f7ce3511b", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${emailScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "90a1f739-747a-450a-8209-068c2c1fe01e", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "id": "4f1f6ef0-40ff-4cf7-9f72-3f289a2b4d8c", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "692f8ce3-6837-482d-957a-51b2cb960a8a", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${addressScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "2b15031e-dba3-4c8e-9fca-29fe2b65032d", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "introspection.token.claim": "true", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "60aafcd0-8ab0-4887-bafe-b35b98017ad8", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "0529840e-14f1-4745-9d8c-fdc77ea9834f", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "7680a7ac-84c0-4af7-b008-d41bd005a63b", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String" + } + }, + { + "id": "116c58b8-5298-4904-9449-75cd97b7692d", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + }, + { + "id": "94117bc7-add2-4b47-ab8f-604e47370b82", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "dff30f03-98dc-4865-b70a-2858bbe72206", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${phoneScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "cc835187-32cf-431d-a32c-43eda60242ce", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "7468b0cd-786c-45ff-bc04-931be2a14487", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "1e2f8af4-f400-40b7-a683-26ada3b2b8d9", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "35f853c3-cba3-441a-aa0b-57712aa90c59", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + }, + { + "id": "59b3a7dd-2ec6-460b-a157-873ecd956199", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "71ddc4ab-f605-4fc4-ac97-2a22e3199c5f", + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "f218de14-1a9c-467e-9360-52256d357e13", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "profile", + "email", + "roles", + "web-origins", + "acr" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "referrerPolicy": "no-referrer", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "loginTheme": "truthlens", + "accountTheme": "", + "adminTheme": "", + "emailTheme": "", + "eventsEnabled": false, + "eventsListeners": [ + "jboss-logging" + ], + "enabledEventTypes": [ + "UPDATE_CONSENT_ERROR", + "SEND_RESET_PASSWORD", + "GRANT_CONSENT", + "VERIFY_PROFILE_ERROR", + "UPDATE_TOTP", + "REMOVE_TOTP", + "REVOKE_GRANT", + "LOGIN_ERROR", + "CLIENT_LOGIN", + "IDENTITY_PROVIDER_RETRIEVE_TOKEN_ERROR", + "RESET_PASSWORD_ERROR", + "IMPERSONATE_ERROR", + "CODE_TO_TOKEN_ERROR", + "CUSTOM_REQUIRED_ACTION", + "OAUTH2_DEVICE_CODE_TO_TOKEN_ERROR", + "RESTART_AUTHENTICATION", + "CLIENT_INFO", + "UPDATE_PROFILE_ERROR", + "IMPERSONATE", + "LOGIN", + "UPDATE_PASSWORD_ERROR", + "OAUTH2_DEVICE_VERIFY_USER_CODE", + "CLIENT_INITIATED_ACCOUNT_LINKING", + "USER_DISABLED_BY_PERMANENT_LOCKOUT", + "OAUTH2_EXTENSION_GRANT", + "IDENTITY_PROVIDER_LOGIN", + "TOKEN_EXCHANGE", + "REGISTER", + "LOGOUT", + "AUTHREQID_TO_TOKEN", + "CLIENT_INFO_ERROR", + "DELETE_ACCOUNT_ERROR", + "CLIENT_REGISTER", + "IDENTITY_PROVIDER_LINK_ACCOUNT", + "INTROSPECT_TOKEN_ERROR", + "USER_DISABLED_BY_TEMPORARY_LOCKOUT", + "UPDATE_PASSWORD", + "DELETE_ACCOUNT", + "INTROSPECT_TOKEN", + "FEDERATED_IDENTITY_LINK_ERROR", + "CLIENT_DELETE", + "IDENTITY_PROVIDER_FIRST_LOGIN", + "VERIFY_EMAIL", + "CLIENT_DELETE_ERROR", + "CLIENT_LOGIN_ERROR", + "RESTART_AUTHENTICATION_ERROR", + "REMOVE_FEDERATED_IDENTITY_ERROR", + "EXECUTE_ACTIONS", + "TOKEN_EXCHANGE_ERROR", + "PERMISSION_TOKEN", + "SEND_IDENTITY_PROVIDER_LINK_ERROR", + "INVALID_SIGNATURE", + "EXECUTE_ACTION_TOKEN_ERROR", + "SEND_VERIFY_EMAIL", + "OAUTH2_EXTENSION_GRANT_ERROR", + "OAUTH2_DEVICE_AUTH", + "IDENTITY_PROVIDER_RESPONSE", + "EXECUTE_ACTIONS_ERROR", + "REMOVE_FEDERATED_IDENTITY", + "IDENTITY_PROVIDER_RETRIEVE_TOKEN", + "OAUTH2_DEVICE_CODE_TO_TOKEN", + "IDENTITY_PROVIDER_POST_LOGIN", + "IDENTITY_PROVIDER_LINK_ACCOUNT_ERROR", + "UPDATE_EMAIL", + "OAUTH2_DEVICE_VERIFY_USER_CODE_ERROR", + "REGISTER_ERROR", + "REVOKE_GRANT_ERROR", + "LOGOUT_ERROR", + "UPDATE_EMAIL_ERROR", + "EXECUTE_ACTION_TOKEN", + "CLIENT_UPDATE_ERROR", + "UPDATE_PROFILE", + "AUTHREQID_TO_TOKEN_ERROR", + "FEDERATED_IDENTITY_LINK", + "CLIENT_REGISTER_ERROR", + "IDENTITY_PROVIDER_RESPONSE_ERROR", + "SEND_VERIFY_EMAIL_ERROR", + "SEND_IDENTITY_PROVIDER_LINK", + "IDENTITY_PROVIDER_LOGIN_ERROR", + "RESET_PASSWORD", + "CLIENT_INITIATED_ACCOUNT_LINKING_ERROR", + "OAUTH2_DEVICE_AUTH_ERROR", + "UPDATE_CONSENT", + "REMOVE_TOTP_ERROR", + "VERIFY_EMAIL_ERROR", + "SEND_RESET_PASSWORD_ERROR", + "CLIENT_UPDATE", + "IDENTITY_PROVIDER_POST_LOGIN_ERROR", + "CUSTOM_REQUIRED_ACTION_ERROR", + "UPDATE_TOTP_ERROR", + "CODE_TO_TOKEN", + "VERIFY_PROFILE", + "GRANT_CONSENT_ERROR", + "IDENTITY_PROVIDER_FIRST_LOGIN_ERROR" + ], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "ca2287e5-7bab-4b4f-bbdd-ad530720644b", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "5d03ae56-f98e-4169-aef6-f04110dba6b9", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": [ + "200" + ] + } + }, + { + "id": "7b32ca72-1d6e-4976-9d46-93de4b6f7195", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "saml-role-list-mapper", + "oidc-full-name-mapper", + "oidc-usermodel-property-mapper", + "saml-user-property-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-address-mapper", + "saml-user-attribute-mapper", + "oidc-usermodel-attribute-mapper" + ] + } + }, + { + "id": "25929940-c581-4538-8c6d-d1c81b8c4498", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "7e0358a8-d54f-4cd9-956d-bc6f5c9f0ffc", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "saml-user-property-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-usermodel-property-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-full-name-mapper", + "saml-role-list-mapper", + "saml-user-attribute-mapper", + "oidc-address-mapper" + ] + } + }, + { + "id": "f8c951a9-0a4d-455c-9ad1-4ef03ef96367", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": [ + "true" + ], + "client-uris-must-match": [ + "true" + ] + } + }, + { + "id": "1b84569a-4d4d-4b95-b3f7-eb4b75a9f12d", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "1b9e2ff0-32c0-4af7-8587-41102540c78f", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "e0b01176-2070-4703-9cd2-f8e75d393e61", + "name": "rsa-enc-generated", + "providerId": "rsa-enc-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "RSA-OAEP" + ] + } + }, + { + "id": "3347346c-f2cc-42bf-94c7-0d2f7232d40b", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "5b5495bd-fdc4-4d82-a4a3-a70d70c5e352", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "c8e65f5e-6d4b-4dda-8a2b-ea28cd4c5864", + "name": "hmac-generated-hs512", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "HS512" + ] + } + } + ] + }, + "internationalizationEnabled": false, + "supportedLocales": [], + "authenticationFlows": [ + { + "id": "5692f167-4c05-447e-801f-6f8ad0a55295", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false + } + ] + }, + { + "id": "943772fa-43bb-4821-b948-f5bcd98a26e5", + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "847d194e-ca72-4439-9f38-6c13a4494c18", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "85b41ade-a941-4636-8fac-612b721999c5", + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "f47894b7-8142-482e-b5b0-f103a2f5a51a", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Account verification options", + "userSetupAllowed": false + } + ] + }, + { + "id": "b5f015e6-f851-40e7-a515-ef8f7346affc", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "e2338158-2804-4c77-8e5f-60e329223c62", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false + } + ] + }, + { + "id": "b54eee75-1790-4951-8a80-f98ef4204b1f", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "2a9c110e-3cbf-45ea-bbea-fc100884e039", + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "forms", + "userSetupAllowed": false + } + ] + }, + { + "id": "c82fc370-280d-4786-ba71-13f3bfa41f70", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "3fa6e7e9-beb0-4d0b-a93d-3c39f78f97c3", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "188be954-5895-4f41-92a5-3424e3cb3ee1", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "3706a945-23bb-45e7-8fda-948e9f40061c", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "User creation or linking", + "userSetupAllowed": false + } + ] + }, + { + "id": "80103fef-2032-41fc-8526-878982c4527e", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "b37838c4-b2ed-4ac6-9cbc-71c839b1c83d", + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "registration form", + "userSetupAllowed": false + } + ] + }, + { + "id": "1b4208af-6dff-402e-93fc-abb86d7c01e2", + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-terms-and-conditions", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 70, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "070e99a7-4fee-4cb4-ae06-8b65dbcaae57", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "autheticatorFlow": true, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "74fe1781-4ee9-4761-9703-d63938d79a3f", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "a9da2248-6590-4ddf-b115-3227b949cbba", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "3679f847-498a-4eb0-a802-be5aef3bfa26", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "TERMS_AND_CONDITIONS", + "name": "Terms and Conditions", + "providerId": "TERMS_AND_CONDITIONS", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "webauthn-register", + "name": "Webauthn Register", + "providerId": "webauthn-register", + "enabled": true, + "defaultAction": false, + "priority": 70, + "config": {} + }, + { + "alias": "webauthn-register-passwordless", + "name": "Webauthn Register Passwordless", + "providerId": "webauthn-register-passwordless", + "enabled": true, + "defaultAction": false, + "priority": 80, + "config": {} + }, + { + "alias": "VERIFY_PROFILE", + "name": "Verify Profile", + "providerId": "VERIFY_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 90, + "config": {} + }, + { + "alias": "delete_credential", + "name": "Delete Credential", + "providerId": "delete_credential", + "enabled": true, + "defaultAction": false, + "priority": 100, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "firstBrokerLoginFlow": "first broker login", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaAuthRequestedUserHint": "login_hint", + "oauth2DevicePollingInterval": "5", + "clientOfflineSessionMaxLifespan": "0", + "clientSessionIdleTimeout": "0", + "clientOfflineSessionIdleTimeout": "0", + "cibaInterval": "5", + "realmReusableOtpCode": "false", + "cibaExpiresIn": "120", + "oauth2DeviceCodeLifespan": "600", + "parRequestUriLifespan": "60", + "clientSessionMaxLifespan": "0", + "frontendUrl": "", + "acr.loa.map": "{}" + }, + "keycloakVersion": "24.0.3", + "userManagedAccessAllowed": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } +} \ No newline at end of file diff --git a/keycloak-theme/truthlens/login/resources/css/styles.css b/keycloak-theme/truthlens/login/resources/css/styles.css new file mode 100644 index 0000000..960a65c --- /dev/null +++ b/keycloak-theme/truthlens/login/resources/css/styles.css @@ -0,0 +1,242 @@ +@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&display=swap'); + +:root { + --bg-color: #050505; + --text-color: #ffffff; + --neon-purple: #bc13fe; + --neon-orange: #ff6700; + --glass-bg: rgba(255, 255, 255, 0.05); + --glass-border: rgba(255, 255, 255, 0.12); + --glass-blur: blur(25px); +} + +html, +body { + background: var(--bg-color) !important; + color: var(--text-color) !important; + font-family: 'Outfit', sans-serif !important; + margin: 0 !important; + height: 100% !important; +} + + +body { + background: radial-gradient(circle at 15% 20%, rgba(188, 19, 254, 0.2) 0%, transparent 45%), + radial-gradient(circle at 85% 80%, rgba(255, 103, 0, 0.2) 0%, transparent 45%), + var(--bg-color) !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; +} + +#kc-header { + display: none !important; +} + + +.card-pf, +.pf-c-card, +.login-pf-page .card-pf { + background: var(--glass-bg) !important; + backdrop-filter: var(--glass-blur) !important; + -webkit-backdrop-filter: var(--glass-blur) !important; + border: 1px solid var(--glass-border) !important; + box-shadow: 0 0 40px rgba(0, 0, 0, 0.5), + 0 0 20px rgba(188, 19, 254, 0.1) !important; + border-radius: 24px !important; + padding: 3rem 2.5rem !important; + max-width: 480px !important; + width: 90% !important; + margin: auto !important; + transition: all 0.4s ease !important; +} + +.card-pf:hover { + border-color: rgba(188, 19, 254, 0.3) !important; + box-shadow: 0 0 50px rgba(0, 0, 0, 0.6), + 0 0 30px rgba(188, 19, 254, 0.2) !important; +} + +#kc-page-title { + color: var(--text-color) !important; + font-size: 2.25rem !important; + font-weight: 800 !important; + text-align: center !important; + margin-bottom: 2.5rem !important; + letter-spacing: -0.03em !important; + border: none !important; +} + + +#kc-page-title::after { + content: 'TruthLens Verification'; + display: block; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 4px; + color: var(--neon-purple); + margin-top: 0.75rem; + text-shadow: 0 0 10px rgba(188, 19, 254, 0.8); + font-weight: 600; +} + +.form-group label, +.pf-c-form__label { + color: var(--text-color) !important; + font-size: 0.8rem !important; + text-transform: uppercase !important; + letter-spacing: 1.5px !important; + opacity: 0.7 !important; + margin-bottom: 0.75rem !important; + font-weight: 600 !important; +} + +.form-control, +.pf-c-form-control, +.pf-c-input-group { + background: rgba(255, 255, 255, 0.03) !important; + border: 1px solid var(--glass-border) !important; + border-radius: 12px !important; + color: white !important; + padding: 14px 18px !important; + height: auto !important; + transition: all 0.3s !important; +} + +.pf-c-input-group .pf-c-form-control { + border: none !important; + background: transparent !important; + padding-right: 0 !important; +} + +.pf-c-input-group button, +.pf-c-input-group .pf-c-button { + background: transparent !important; + border: none !important; + color: white !important; + padding: 0 15px !important; + opacity: 0.7 !important; +} + +.form-control:focus, +.pf-c-form-control:focus, +.pf-c-input-group:focus-within { + background: rgba(255, 255, 255, 0.07) !important; + border-color: var(--neon-purple) !important; + box-shadow: 0 0 15px rgba(188, 19, 254, 0.3) !important; + outline: none !important; +} + + +.btn-primary, +.pf-m-primary { + background: linear-gradient(135deg, rgba(188, 19, 254, 0.3) 0%, rgba(188, 19, 254, 0.1) 100%) !important; + border: 1px solid var(--neon-purple) !important; + color: white !important; + font-weight: 700 !important; + padding: 14px !important; + border-radius: 12px !important; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important; + text-transform: uppercase !important; + letter-spacing: 2px !important; + margin-top: 1.5rem !important; + cursor: pointer !important; +} + +.btn-primary:hover, +.pf-m-primary:hover { + background: rgba(188, 19, 254, 0.5) !important; + box-shadow: 0 0 25px rgba(188, 19, 254, 0.5) !important; + transform: translateY(-3px) !important; +} + +.btn-primary:active { + transform: translateY(-1px) !important; +} + + +#kc-registration, +.login-pf-signup { + margin-top: 2rem !important; + text-align: center !important; + color: rgba(255, 255, 255, 0.5) !important; + font-size: 0.9rem !important; +} + +#kc-registration a, +.login-pf-signup a { + color: var(--neon-orange) !important; + font-weight: 700 !important; + text-decoration: none !important; + margin-left: 5px !important; + transition: all 0.3s !important; +} + +#kc-registration a:hover, +.login-pf-signup a:hover { + text-shadow: 0 0 10px rgba(255, 103, 0, 0.6) !important; + text-decoration: underline !important; +} + + +#kc-login-options, +.login-pf-settings { + display: none !important; +} + +#kc-info-wrapper { + background: none !important; + border: none !important; +} + + +input[type="checkbox"] { + accent-color: var(--neon-purple) !important; +} + + +#kc-feedback, +.alert, +.pf-c-alert { + background: rgba(255, 255, 255, 0.05) !important; + border-radius: 12px !important; + padding: 14px 18px !important; + margin-bottom: 1.5rem !important; + border: 1px solid var(--glass-border) !important; + display: flex !important; + align-items: center !important; + gap: 10px !important; +} + + +.alert-error, +.pf-m-danger, +.pf-c-alert.pf-m-danger { + border-color: #ff3333 !important; + background: rgba(255, 51, 51, 0.08) !important; + box-shadow: 0 0 15px rgba(255, 51, 51, 0.15) !important; +} + +.alert-error .kc-feedback-text, +.pf-c-alert__title, +.pf-c-alert__description, +.kc-feedback-text { + color: #ff5555 !important; + font-weight: 600 !important; + font-size: 0.9rem !important; +} + + +.alert-success, +.pf-m-success, +.pf-c-alert.pf-m-success { + border-color: #00e676 !important; + background: rgba(0, 230, 118, 0.08) !important; + box-shadow: 0 0 15px rgba(0, 230, 118, 0.15) !important; +} + +.alert-success .kc-feedback-text { + color: #00e676 !important; + font-weight: 600 !important; + font-size: 0.9rem !important; +} \ No newline at end of file diff --git a/keycloak-theme/truthlens/login/theme.properties b/keycloak-theme/truthlens/login/theme.properties new file mode 100644 index 0000000..e7f6c91 --- /dev/null +++ b/keycloak-theme/truthlens/login/theme.properties @@ -0,0 +1,2 @@ +parent=keycloak +styles=css/styles.css diff --git a/mongo-init/init-mongo.js b/mongo-init/init-mongo.js new file mode 100644 index 0000000..86916db --- /dev/null +++ b/mongo-init/init-mongo.js @@ -0,0 +1,12 @@ +db = db.getSiblingDB('truthlens_history'); + +db.createUser({ + user: 'truthlens_app', + pwd: 'truthlens_mongo_pass', + roles: [ + { role: 'readWrite', db: 'truthlens_history' } + ] +}); + +db.createCollection('verification_history'); +db.verification_history.createIndex({ userId: 1, createdAt: -1 }); diff --git a/ocr-service/Dockerfile b/ocr-service/Dockerfile new file mode 100644 index 0000000..185674d --- /dev/null +++ b/ocr-service/Dockerfile @@ -0,0 +1,13 @@ +FROM maven:3.9-eclipse-temurin-25-alpine AS build +WORKDIR /app +COPY pom.xml . +RUN mvn dependency:go-offline -B + +COPY src src +RUN mvn clean package -DskipTests + +FROM eclipse-temurin:25-jre-alpine +WORKDIR /app +COPY --from=build /app/target/*.jar app.jar + +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/ocr-service/pom.xml b/ocr-service/pom.xml new file mode 100644 index 0000000..f89e7ea --- /dev/null +++ b/ocr-service/pom.xml @@ -0,0 +1,118 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.11 + + + + com.truthlens + ocr-service + 0.0.1-SNAPSHOT + TruthLens OCR Service + Internal microservice — image-to-text extraction via Vision AI (Gemini Flash) + + + 25 + 2025.0.1 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-client + + + + + org.springframework.cloud + spring-cloud-starter-config + + + + + org.springframework.retry + spring-retry + + + org.springframework.boot + spring-boot-starter-aop + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.5 + + + + + io.github.cdimascio + dotenv-java + 3.0.0 + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.jacoco + jacoco-maven-plugin + 0.8.14 + + + + prepare-agent + + + + report + verify + + report + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/ocr-service/src/main/java/com/truthlens/ocr/OcrServiceApplication.java b/ocr-service/src/main/java/com/truthlens/ocr/OcrServiceApplication.java new file mode 100644 index 0000000..d353b90 --- /dev/null +++ b/ocr-service/src/main/java/com/truthlens/ocr/OcrServiceApplication.java @@ -0,0 +1,14 @@ +package com.truthlens.ocr; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; + +@SpringBootApplication +@EnableDiscoveryClient +public class OcrServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(OcrServiceApplication.class, args); + } +} diff --git a/ocr-service/src/main/java/com/truthlens/ocr/config/GeminiClientConfig.java b/ocr-service/src/main/java/com/truthlens/ocr/config/GeminiClientConfig.java new file mode 100644 index 0000000..636999e --- /dev/null +++ b/ocr-service/src/main/java/com/truthlens/ocr/config/GeminiClientConfig.java @@ -0,0 +1,32 @@ +package com.truthlens.ocr.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClient; +@Configuration +public class GeminiClientConfig { + + private final String geminiApiKey; + + public GeminiClientConfig(@Value("${gemini.api.key}") String geminiApiKey) { + this.geminiApiKey = geminiApiKey; + } + + @Bean + public RestClient geminiRestClient(RestClient.Builder builder) { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(10000); + factory.setReadTimeout(60000); + + String geminiUrl = "https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-latest:generateContent"; + + return builder + .baseUrl(geminiUrl) + .defaultHeader("x-goog-api-key", geminiApiKey) + .defaultHeader("Content-Type", "application/json") + .requestFactory(factory) + .build(); + } +} \ No newline at end of file diff --git a/ocr-service/src/main/java/com/truthlens/ocr/config/OpenApiConfig.java b/ocr-service/src/main/java/com/truthlens/ocr/config/OpenApiConfig.java new file mode 100644 index 0000000..92945d4 --- /dev/null +++ b/ocr-service/src/main/java/com/truthlens/ocr/config/OpenApiConfig.java @@ -0,0 +1,24 @@ +package com.truthlens.ocr.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.annotations.servers.Server; +import org.springframework.context.annotation.Configuration; + +@Configuration +@OpenAPIDefinition( + info = @Info(title = "TruthLens OCR API", version = "v1"), + security = @SecurityRequirement(name = "bearerAuth"), + servers = {@Server(url = "/", description = "Default Server URL")} +) +@SecurityScheme( + name = "bearerAuth", + type = SecuritySchemeType.HTTP, + bearerFormat = "JWT", + scheme = "bearer" +) +public class OpenApiConfig { +} diff --git a/ocr-service/src/main/java/com/truthlens/ocr/controller/OcrController.java b/ocr-service/src/main/java/com/truthlens/ocr/controller/OcrController.java new file mode 100644 index 0000000..ed1c249 --- /dev/null +++ b/ocr-service/src/main/java/com/truthlens/ocr/controller/OcrController.java @@ -0,0 +1,24 @@ +package com.truthlens.ocr.controller; + +import com.truthlens.ocr.model.OcrExtractionResponse; +import com.truthlens.ocr.service.VisionExtractionService; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +public class OcrController { + + private final VisionExtractionService visionExtractionService; + + public OcrController(VisionExtractionService visionExtractionService) { + this.visionExtractionService = visionExtractionService; + } + + @PostMapping(value = "/api/internal/v1/ocr:extract", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public OcrExtractionResponse extract(@RequestParam("image") MultipartFile image) { + return visionExtractionService.extractText(image); + } +} \ No newline at end of file diff --git a/ocr-service/src/main/java/com/truthlens/ocr/exception/GlobalExceptionHandler.java b/ocr-service/src/main/java/com/truthlens/ocr/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..28022b8 --- /dev/null +++ b/ocr-service/src/main/java/com/truthlens/ocr/exception/GlobalExceptionHandler.java @@ -0,0 +1,26 @@ +package com.truthlens.ocr.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException ex) { + return ResponseEntity + .status(HttpStatus.UNPROCESSABLE_ENTITY) + .body(Map.of("error", ex.getMessage())); + } + + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity> handleIllegalStateException(IllegalStateException ex) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", ex.getMessage())); + } +} \ No newline at end of file diff --git a/ocr-service/src/main/java/com/truthlens/ocr/model/OcrExtractionResponse.java b/ocr-service/src/main/java/com/truthlens/ocr/model/OcrExtractionResponse.java new file mode 100644 index 0000000..051ff86 --- /dev/null +++ b/ocr-service/src/main/java/com/truthlens/ocr/model/OcrExtractionResponse.java @@ -0,0 +1,7 @@ +package com.truthlens.ocr.model; + +public record OcrExtractionResponse( + String extractedText, + boolean hasManipulationArtifacts, + double confidenceScore +) {} \ No newline at end of file diff --git a/ocr-service/src/main/java/com/truthlens/ocr/model/gemini/GeminiCandidate.java b/ocr-service/src/main/java/com/truthlens/ocr/model/gemini/GeminiCandidate.java new file mode 100644 index 0000000..290d0a8 --- /dev/null +++ b/ocr-service/src/main/java/com/truthlens/ocr/model/gemini/GeminiCandidate.java @@ -0,0 +1,3 @@ +package com.truthlens.ocr.model.gemini; + +public record GeminiCandidate(GeminiContent content) {} \ No newline at end of file diff --git a/ocr-service/src/main/java/com/truthlens/ocr/model/gemini/GeminiContent.java b/ocr-service/src/main/java/com/truthlens/ocr/model/gemini/GeminiContent.java new file mode 100644 index 0000000..b8ff959 --- /dev/null +++ b/ocr-service/src/main/java/com/truthlens/ocr/model/gemini/GeminiContent.java @@ -0,0 +1,4 @@ +package com.truthlens.ocr.model.gemini; +import java.util.List; + +public record GeminiContent(List parts) {} \ No newline at end of file diff --git a/ocr-service/src/main/java/com/truthlens/ocr/model/gemini/GeminiInlineData.java b/ocr-service/src/main/java/com/truthlens/ocr/model/gemini/GeminiInlineData.java new file mode 100644 index 0000000..55119cc --- /dev/null +++ b/ocr-service/src/main/java/com/truthlens/ocr/model/gemini/GeminiInlineData.java @@ -0,0 +1,7 @@ +package com.truthlens.ocr.model.gemini; +import com.fasterxml.jackson.annotation.JsonProperty; + +public record GeminiInlineData( + @JsonProperty("mime_type") String mimeType, + String data +) {} \ No newline at end of file diff --git a/ocr-service/src/main/java/com/truthlens/ocr/model/gemini/GeminiPart.java b/ocr-service/src/main/java/com/truthlens/ocr/model/gemini/GeminiPart.java new file mode 100644 index 0000000..3841df3 --- /dev/null +++ b/ocr-service/src/main/java/com/truthlens/ocr/model/gemini/GeminiPart.java @@ -0,0 +1,7 @@ +package com.truthlens.ocr.model.gemini; +import com.fasterxml.jackson.annotation.JsonProperty; + +public record GeminiPart( + String text, + @JsonProperty("inline_data") GeminiInlineData inlineData +) {} \ No newline at end of file diff --git a/ocr-service/src/main/java/com/truthlens/ocr/model/gemini/GeminiRequest.java b/ocr-service/src/main/java/com/truthlens/ocr/model/gemini/GeminiRequest.java new file mode 100644 index 0000000..af76c69 --- /dev/null +++ b/ocr-service/src/main/java/com/truthlens/ocr/model/gemini/GeminiRequest.java @@ -0,0 +1,4 @@ +package com.truthlens.ocr.model.gemini; +import java.util.List; + +public record GeminiRequest(List contents) {} \ No newline at end of file diff --git a/ocr-service/src/main/java/com/truthlens/ocr/model/gemini/GeminiResponse.java b/ocr-service/src/main/java/com/truthlens/ocr/model/gemini/GeminiResponse.java new file mode 100644 index 0000000..9ea1952 --- /dev/null +++ b/ocr-service/src/main/java/com/truthlens/ocr/model/gemini/GeminiResponse.java @@ -0,0 +1,4 @@ +package com.truthlens.ocr.model.gemini; +import java.util.List; + +public record GeminiResponse(List candidates) {} diff --git a/ocr-service/src/main/java/com/truthlens/ocr/service/VisionExtractionService.java b/ocr-service/src/main/java/com/truthlens/ocr/service/VisionExtractionService.java new file mode 100644 index 0000000..515f475 --- /dev/null +++ b/ocr-service/src/main/java/com/truthlens/ocr/service/VisionExtractionService.java @@ -0,0 +1,109 @@ +package com.truthlens.ocr.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.truthlens.ocr.model.OcrExtractionResponse; +import com.truthlens.ocr.model.gemini.*; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.Base64; +import java.util.List; + +@Service +public class VisionExtractionService { + + private final RestClient geminiRestClient; + private final ObjectMapper objectMapper; + + + public VisionExtractionService(RestClient geminiRestClient, ObjectMapper objectMapper) { + this.geminiRestClient = geminiRestClient; + this.objectMapper = objectMapper; + } + + @Retryable( + retryFor = {HttpServerErrorException.class, ResourceAccessException.class}, + backoff = @Backoff(delay = 1000, multiplier = 2) + ) + public OcrExtractionResponse extractText(MultipartFile image) { + validateImage(image); + + String base64Image; + try { + base64Image = Base64.getEncoder().encodeToString(image.getBytes()); + } catch (IOException e) { + throw new IllegalArgumentException("Nie można odczytać pliku obrazu.", e); + } + + String mimeType = image.getContentType(); + if (mimeType == null) { + mimeType = "image/jpeg"; + } + + String prompt = """ + Extract all text from this image. Note any obvious artifacts suggesting cheap photomontage. + Return the response STRICTLY as a JSON object with the following keys: + - "extractedText": (string) The full text found in the image. Leave empty if no text. + - "hasManipulationArtifacts": (boolean) true if obvious montage artifacts are found, false otherwise. + - "confidenceScore": (number) 0 to 100 rating your confidence. + Ensure the output is pure JSON without Markdown formatting. + """; + + GeminiRequest requestBody = new GeminiRequest(List.of( + new GeminiContent(List.of( + new GeminiPart(prompt, null), + new GeminiPart(null, new GeminiInlineData(mimeType, base64Image)) + )) + )); + + GeminiResponse response = geminiRestClient.post() + .body(requestBody) + .retrieve() + .body(GeminiResponse.class); + + return parseGeminiResponse(response); + } + + private void validateImage(MultipartFile image) { + if (image == null || image.isEmpty()) { + throw new IllegalArgumentException("Przesłany plik graficzny jest pusty."); + } + String contentType = image.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + throw new IllegalArgumentException("Przesłany plik nie jest obsługiwanym obrazem."); + } + } + + private OcrExtractionResponse parseGeminiResponse(GeminiResponse response) { + if (response == null || response.candidates() == null || response.candidates().isEmpty()) { + throw new IllegalStateException("Otrzymano pustą odpowiedź od modelu AI."); + } + + String rawJson = response.candidates().get(0).content().parts().get(0).text(); + + if (rawJson.startsWith("```json")) { + rawJson = rawJson.replace("```json", "").replace("```", "").trim(); + } else if (rawJson.startsWith("```")) { + rawJson = rawJson.replace("```", "").trim(); + } + + try { + OcrExtractionResponse extracted = objectMapper.readValue(rawJson, OcrExtractionResponse.class); + + if (extracted.extractedText() == null || extracted.extractedText().trim().isEmpty()) { + throw new IllegalArgumentException("Z przesłanego obrazu nie da się odczytać żadnego tekstu."); + } + + return extracted; + } catch (JsonProcessingException e) { + throw new IllegalStateException("Nie udało się sparsować odpowiedzi JSON od modelu Gemini.", e); + } + } +} \ No newline at end of file diff --git a/ocr-service/src/main/resources/application.yml b/ocr-service/src/main/resources/application.yml new file mode 100644 index 0000000..eac111b --- /dev/null +++ b/ocr-service/src/main/resources/application.yml @@ -0,0 +1,13 @@ +spring: + application: + name: truthlens-ocr-service + config: + import: "optional:configserver:http://truthlens-config-server:8888" + cloud: + config: + fail-fast: true + retry: + initial-interval: 2000 + max-interval: 10000 + multiplier: 1.5 + max-attempts: 15 diff --git a/ocr-service/src/test/java/com/truthlens/ocr/OcrServiceApplicationTest.java b/ocr-service/src/test/java/com/truthlens/ocr/OcrServiceApplicationTest.java new file mode 100644 index 0000000..68e2c8f --- /dev/null +++ b/ocr-service/src/test/java/com/truthlens/ocr/OcrServiceApplicationTest.java @@ -0,0 +1,30 @@ +package com.truthlens.ocr; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(properties = { + "spring.cloud.discovery.enabled=false", + "spring.cloud.config.enabled=false", + "spring.cloud.config.fail-fast=false", + "gemini.api.key=dummy" +}) +class OcrServiceApplicationTest { + + @Test + void contextLoads() { + Assertions.assertDoesNotThrow(() -> {}); + } + + @Test + void mainMethodLoads() { + Assertions.assertDoesNotThrow(() -> OcrServiceApplication.main(new String[] { + "--server.port=0", + "--spring.cloud.discovery.enabled=false", + "--spring.cloud.config.enabled=false", + "--spring.cloud.config.fail-fast=false", + "--gemini.api.key=dummy" + })); + } +} diff --git a/ocr-service/src/test/java/com/truthlens/ocr/config/GeminiClientConfigTest.java b/ocr-service/src/test/java/com/truthlens/ocr/config/GeminiClientConfigTest.java new file mode 100644 index 0000000..dd4eea3 --- /dev/null +++ b/ocr-service/src/test/java/com/truthlens/ocr/config/GeminiClientConfigTest.java @@ -0,0 +1,16 @@ +package com.truthlens.ocr.config; + +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; + +class GeminiClientConfigTest { + + @Test + void geminiRestClient() { + GeminiClientConfig config = new GeminiClientConfig("test-key"); + RestClient client = config.geminiRestClient(RestClient.builder()); + assertThat(client).isNotNull(); + } +} diff --git a/ocr-service/src/test/java/com/truthlens/ocr/controller/OcrControllerTest.java b/ocr-service/src/test/java/com/truthlens/ocr/controller/OcrControllerTest.java new file mode 100644 index 0000000..5d54a9d --- /dev/null +++ b/ocr-service/src/test/java/com/truthlens/ocr/controller/OcrControllerTest.java @@ -0,0 +1,35 @@ +package com.truthlens.ocr.controller; + +import com.truthlens.ocr.model.OcrExtractionResponse; +import com.truthlens.ocr.service.VisionExtractionService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class OcrControllerTest { + + private VisionExtractionService visionExtractionService; + private OcrController ocrController; + + @BeforeEach + void setUp() { + visionExtractionService = mock(VisionExtractionService.class); + ocrController = new OcrController(visionExtractionService); + } + + @Test + void extract() { + MockMultipartFile file = new MockMultipartFile("image", "test.jpg", "image/jpeg", "test data".getBytes()); + OcrExtractionResponse response = new OcrExtractionResponse("text", false, 99.0); + + when(visionExtractionService.extractText(file)).thenReturn(response); + + OcrExtractionResponse result = ocrController.extract(file); + + assertThat(result).isEqualTo(response); + verify(visionExtractionService).extractText(file); + } +} diff --git a/ocr-service/src/test/java/com/truthlens/ocr/exception/GlobalExceptionHandlerTest.java b/ocr-service/src/test/java/com/truthlens/ocr/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..d829910 --- /dev/null +++ b/ocr-service/src/test/java/com/truthlens/ocr/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,34 @@ +package com.truthlens.ocr.exception; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class GlobalExceptionHandlerTest { + + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); + + @Test + void handleIllegalArgumentException() { + IllegalArgumentException ex = new IllegalArgumentException("Test error"); + ResponseEntity> response = handler.handleIllegalArgumentException(ex); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody()).containsEntry("error", "Test error"); + } + + @Test + void handleIllegalStateException() { + IllegalStateException ex = new IllegalStateException("Test state error"); + ResponseEntity> response = handler.handleIllegalStateException(ex); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody()).containsEntry("error", "Test state error"); + } +} diff --git a/ocr-service/src/test/java/com/truthlens/ocr/model/ModelCoverageTest.java b/ocr-service/src/test/java/com/truthlens/ocr/model/ModelCoverageTest.java new file mode 100644 index 0000000..0916ca1 --- /dev/null +++ b/ocr-service/src/test/java/com/truthlens/ocr/model/ModelCoverageTest.java @@ -0,0 +1,87 @@ +package com.truthlens.ocr.model; + +import com.truthlens.ocr.model.gemini.*; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ModelCoverageTest { + + @Test + void testOcrExtractionResponse() { + OcrExtractionResponse response = new OcrExtractionResponse("text", true, 80.0); + assertThat(response.extractedText()).isEqualTo("text"); + assertThat(response.hasManipulationArtifacts()).isTrue(); + assertThat(response.confidenceScore()).isEqualTo(80.0); + + assertThat(response.toString()).contains("text"); + assertThat(response.hashCode()).isNotZero(); + assertThat(response).isEqualTo(new OcrExtractionResponse("text", true, 80.0)); + } + + @Test + void testGeminiInlineData() { + GeminiInlineData inlineData = new GeminiInlineData("image/jpeg", "base64"); + assertThat(inlineData.mimeType()).isEqualTo("image/jpeg"); + assertThat(inlineData.data()).isEqualTo("base64"); + assertThat(inlineData.toString()).contains("base64"); + assertThat(inlineData.hashCode()).isNotZero(); + assertThat(inlineData).isEqualTo(new GeminiInlineData("image/jpeg", "base64")); + } + + @Test + void testGeminiPart() { + GeminiInlineData inlineData = new GeminiInlineData("image/jpeg", "base64"); + GeminiPart part1 = new GeminiPart("prompt", null); + GeminiPart part2 = new GeminiPart(null, inlineData); + assertThat(part1.text()).isEqualTo("prompt"); + assertThat(part2.inlineData()).isEqualTo(inlineData); + assertThat(part1.toString()).contains("prompt"); + assertThat(part1.hashCode()).isNotZero(); + assertThat(part1).isEqualTo(new GeminiPart("prompt", null)); + } + + @Test + void testGeminiContent() { + GeminiPart part1 = new GeminiPart("prompt", null); + GeminiPart part2 = new GeminiPart(null, new GeminiInlineData("image/jpeg", "base64")); + GeminiContent content = new GeminiContent(List.of(part1, part2)); + assertThat(content.parts()).hasSize(2); + assertThat(content.toString()).isNotNull(); + assertThat(content.hashCode()).isNotZero(); + assertThat(content).isEqualTo(new GeminiContent(List.of(part1, part2))); + } + + @Test + void testGeminiCandidate() { + GeminiContent content = new GeminiContent(List.of(new GeminiPart("prompt", null))); + GeminiCandidate candidate = new GeminiCandidate(content); + assertThat(candidate.content()).isEqualTo(content); + assertThat(candidate.toString()).isNotNull(); + assertThat(candidate.hashCode()).isNotZero(); + assertThat(candidate).isEqualTo(new GeminiCandidate(content)); + } + + @Test + void testGeminiRequest() { + GeminiContent content = new GeminiContent(List.of(new GeminiPart("prompt", null))); + GeminiRequest request = new GeminiRequest(List.of(content)); + assertThat(request.contents()).hasSize(1); + assertThat(request.toString()).isNotNull(); + assertThat(request.hashCode()).isNotZero(); + assertThat(request).isEqualTo(new GeminiRequest(List.of(content))); + } + + @Test + void testGeminiResponse() { + GeminiContent content = new GeminiContent(List.of(new GeminiPart("prompt", null))); + GeminiCandidate candidate = new GeminiCandidate(content); + GeminiResponse response = new GeminiResponse(List.of(candidate)); + assertThat(response.candidates()).hasSize(1); + assertThat(response.toString()).isNotNull(); + assertThat(response.hashCode()).isNotZero(); + assertThat(response).isEqualTo(new GeminiResponse(List.of(candidate))); + } +} diff --git a/ocr-service/src/test/java/com/truthlens/ocr/service/VisionExtractionServiceTest.java b/ocr-service/src/test/java/com/truthlens/ocr/service/VisionExtractionServiceTest.java new file mode 100644 index 0000000..d98a4fb --- /dev/null +++ b/ocr-service/src/test/java/com/truthlens/ocr/service/VisionExtractionServiceTest.java @@ -0,0 +1,176 @@ +package com.truthlens.ocr.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.truthlens.ocr.model.OcrExtractionResponse; +import com.truthlens.ocr.model.gemini.GeminiCandidate; +import com.truthlens.ocr.model.gemini.GeminiContent; +import com.truthlens.ocr.model.gemini.GeminiPart; +import com.truthlens.ocr.model.gemini.GeminiResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.client.RestClient; + +import java.io.IOException; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class VisionExtractionServiceTest { + + private RestClient geminiRestClient; + private RestClient.RequestBodyUriSpec requestBodyUriSpec; + private RestClient.RequestBodySpec requestBodySpec; + private RestClient.ResponseSpec responseSpec; + private ObjectMapper objectMapper; + private VisionExtractionService visionExtractionService; + + @BeforeEach + void setUp() { + geminiRestClient = mock(RestClient.class); + requestBodyUriSpec = mock(RestClient.RequestBodyUriSpec.class); + requestBodySpec = mock(RestClient.RequestBodySpec.class); + responseSpec = mock(RestClient.ResponseSpec.class); + + when(geminiRestClient.post()).thenReturn(requestBodyUriSpec); + when(requestBodyUriSpec.body(any(Object.class))).thenReturn(requestBodySpec); + when(requestBodySpec.retrieve()).thenReturn(responseSpec); + + objectMapper = new ObjectMapper(); + visionExtractionService = new VisionExtractionService(geminiRestClient, objectMapper); + } + + @Test + void extractText_Success() { + MockMultipartFile file = new MockMultipartFile("image", "test.jpg", "image/jpeg", "dummy data".getBytes()); + String jsonResponse = "{\"extractedText\": \"Some text\", \"hasManipulationArtifacts\": false, \"confidenceScore\": 95.5}"; + + GeminiResponse geminiResponse = createMockGeminiResponse(jsonResponse); + when(responseSpec.body(GeminiResponse.class)).thenReturn(geminiResponse); + + OcrExtractionResponse result = visionExtractionService.extractText(file); + + assertThat(result).isNotNull(); + assertThat(result.extractedText()).isEqualTo("Some text"); + assertThat(result.hasManipulationArtifacts()).isFalse(); + assertThat(result.confidenceScore()).isEqualTo(95.5); + } + + @Test + void extractText_RemovesMarkdownJsonBlock() { + MockMultipartFile file = new MockMultipartFile("image", "test.jpg", "image/jpeg", "dummy data".getBytes()); + String jsonResponse = "```json\n{\"extractedText\": \"Some text\", \"hasManipulationArtifacts\": false, \"confidenceScore\": 95.5}\n```"; + + GeminiResponse geminiResponse = createMockGeminiResponse(jsonResponse); + when(responseSpec.body(GeminiResponse.class)).thenReturn(geminiResponse); + + OcrExtractionResponse result = visionExtractionService.extractText(file); + + assertThat(result).isNotNull(); + assertThat(result.extractedText()).isEqualTo("Some text"); + } + + @Test + void extractText_ThrowsWhenImageIsNull() { + assertThatThrownBy(() -> visionExtractionService.extractText(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("pust"); + } + + @Test + void extractText_ThrowsWhenImageIsEmpty() { + MockMultipartFile file = new MockMultipartFile("image", "test.jpg", "image/jpeg", new byte[0]); + assertThatThrownBy(() -> visionExtractionService.extractText(file)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("pust"); + } + + @Test + void extractText_ThrowsWhenContentTypeInvalid() { + MockMultipartFile file = new MockMultipartFile("image", "test.txt", "text/plain", "text".getBytes()); + assertThatThrownBy(() -> visionExtractionService.extractText(file)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("obs"); + } + + @Test + void extractText_ThrowsWhenIOExceptionOnRead() { + MockMultipartFile file = new MockMultipartFile("image", "test.jpg", "image/jpeg", "dummy".getBytes()) { + @Override + public byte[] getBytes() throws IOException { + throw new IOException("Failed to read"); + } + }; + assertThatThrownBy(() -> visionExtractionService.extractText(file)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("odczyta"); + } + + @Test + void extractText_ThrowsWhenResponseIsNull() { + MockMultipartFile file = new MockMultipartFile("image", "test.jpg", "image/jpeg", "dummy".getBytes()); + when(responseSpec.body(GeminiResponse.class)).thenReturn(null); + + assertThatThrownBy(() -> visionExtractionService.extractText(file)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("pust"); + } + + @Test + void extractText_ThrowsWhenJsonParsingFails() { + MockMultipartFile file = new MockMultipartFile("image", "test.jpg", "image/jpeg", "dummy".getBytes()); + GeminiResponse geminiResponse = createMockGeminiResponse("{ invalid json }"); + when(responseSpec.body(GeminiResponse.class)).thenReturn(geminiResponse); + + assertThatThrownBy(() -> visionExtractionService.extractText(file)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("sparsowa"); + } + + @Test + void extractText_ThrowsWhenNoTextExtracted() { + MockMultipartFile file = new MockMultipartFile("image", "test.jpg", "image/jpeg", "dummy".getBytes()); + GeminiResponse geminiResponse = createMockGeminiResponse("{\"extractedText\": \"\", \"hasManipulationArtifacts\": false, \"confidenceScore\": 95.5}"); + when(responseSpec.body(GeminiResponse.class)).thenReturn(geminiResponse); + + assertThatThrownBy(() -> visionExtractionService.extractText(file)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("adnego"); + } + + @Test + void extractText_WithNullContentType_UsesDefault() { + MockMultipartFile file = new MockMultipartFile("image", "test.jpg", null, "dummy".getBytes()); + String jsonResponse = "{\"extractedText\": \"Some text\", \"hasManipulationArtifacts\": false, \"confidenceScore\": 95.5}"; + + GeminiResponse geminiResponse = createMockGeminiResponse(jsonResponse); + when(responseSpec.body(GeminiResponse.class)).thenReturn(geminiResponse); + + assertThatThrownBy(() -> visionExtractionService.extractText(file)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("obs"); + } + + @Test + void parseGeminiResponse_RemovesPlainMarkdownBlock() { + MockMultipartFile file = new MockMultipartFile("image", "test.jpg", "image/jpeg", "dummy data".getBytes()); + String jsonResponse = "```\n{\"extractedText\": \"Some text\", \"hasManipulationArtifacts\": false, \"confidenceScore\": 95.5}\n```"; + + GeminiResponse geminiResponse = createMockGeminiResponse(jsonResponse); + when(responseSpec.body(GeminiResponse.class)).thenReturn(geminiResponse); + + OcrExtractionResponse result = visionExtractionService.extractText(file); + + assertThat(result).isNotNull(); + assertThat(result.extractedText()).isEqualTo("Some text"); + } + + private GeminiResponse createMockGeminiResponse(String text) { + return new GeminiResponse(List.of( + new GeminiCandidate(new GeminiContent(List.of(new GeminiPart(text, null)))) + )); + } +} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..197b584 --- /dev/null +++ b/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + + com.truthlens + truthlens-root + 1.0-SNAPSHOT + pom + + TruthLens + TruthLens Aggregator Project for SonarQube + + + backend + gateway-service + search-service + ocr-service + discovery-server + config-server + + + + TruthLens + TruthLens + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + + prepare-agent + + + + report + test + + report + + + + + + + + diff --git a/search-service/Dockerfile b/search-service/Dockerfile new file mode 100644 index 0000000..f968113 --- /dev/null +++ b/search-service/Dockerfile @@ -0,0 +1,13 @@ +FROM maven:3.9-eclipse-temurin-25-alpine AS build +WORKDIR /app +COPY pom.xml . +RUN mvn dependency:go-offline -B + +COPY src src +RUN mvn clean package -DskipTests + +FROM eclipse-temurin:25-jre-alpine +WORKDIR /app +COPY --from=build /app/target/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/search-service/pom.xml b/search-service/pom.xml new file mode 100644 index 0000000..1608d86 --- /dev/null +++ b/search-service/pom.xml @@ -0,0 +1,118 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.11 + + + + com.truthlens + search-service + 0.0.1-SNAPSHOT + TruthLens Search Service + Internal microservice — web search aggregation via Tavily / DuckDuckGo + + + 25 + 2025.0.1 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-client + + + + + org.springframework.cloud + spring-cloud-starter-config + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.5 + + + + + org.springframework.retry + spring-retry + + + org.springframework.boot + spring-boot-starter-aop + + + + + io.github.cdimascio + dotenv-java + 3.0.0 + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.jacoco + jacoco-maven-plugin + 0.8.14 + + + + prepare-agent + + + + report + verify + + report + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/search-service/search-service-test.http b/search-service/search-service-test.http new file mode 100644 index 0000000..d8bd355 --- /dev/null +++ b/search-service/search-service-test.http @@ -0,0 +1,52 @@ +### Test 1: Pojedyncze zapytanie +POST http://localhost:8082/api/internal/v1/search:execute +Content-Type: application/json +Connection: close + +{ + "queries": [ + "covid-19 vaccine microchip" + ], + "maxResultsPerQuery": 3 +} + +### + +### Test 2: Zapytanie o konkretny fakt +POST http://localhost:8082/api/internal/v1/search:execute +Content-Type: application/json +Connection: close + +{ + "queries": [ + "who is bill gates" + ], + "maxResultsPerQuery": 2 +} + +### + +### Test 3: Wiele zapytań naraz +POST http://localhost:8082/api/internal/v1/search:execute +Content-Type: application/json +Connection: close + +{ + "queries": [ + "moon landing hoax", + "apollo 11 moon landing facts" + ], + "maxResultsPerQuery": 2 +} + +### + +### Test 4: Błędne zapytanie +POST http://localhost:8082/api/internal/v1/search:execute +Content-Type: application/json +Connection: close + +{ + "queries": [], + "maxResultsPerQuery": 3 +} diff --git a/search-service/src/main/java/com/truthlens/search/SearchServiceApplication.java b/search-service/src/main/java/com/truthlens/search/SearchServiceApplication.java new file mode 100644 index 0000000..57787b4 --- /dev/null +++ b/search-service/src/main/java/com/truthlens/search/SearchServiceApplication.java @@ -0,0 +1,16 @@ +package com.truthlens.search; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; +import org.springframework.retry.annotation.EnableRetry; + +@SpringBootApplication +@EnableDiscoveryClient +@EnableRetry +public class SearchServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(SearchServiceApplication.class, args); + } +} diff --git a/search-service/src/main/java/com/truthlens/search/config/DuckDuckGoClientConfig.java b/search-service/src/main/java/com/truthlens/search/config/DuckDuckGoClientConfig.java new file mode 100644 index 0000000..11c1132 --- /dev/null +++ b/search-service/src/main/java/com/truthlens/search/config/DuckDuckGoClientConfig.java @@ -0,0 +1,34 @@ +package com.truthlens.search.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.retry.annotation.EnableRetry; +import org.springframework.web.client.RestClient; + +import java.util.ArrayList; +import java.util.List; + +@Configuration +@EnableRetry +public class DuckDuckGoClientConfig { + + private static final String DUCKDUCKGO_API_BASE_URL = "https://api.duckduckgo.com"; + + @Bean + public RestClient duckDuckGoRestClient() { + MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); + List supportedMediaTypes = new ArrayList<>(converter.getSupportedMediaTypes()); + supportedMediaTypes.add(new MediaType("application", "x-javascript")); + converter.setSupportedMediaTypes(supportedMediaTypes); + + return RestClient.builder() + .baseUrl(DUCKDUCKGO_API_BASE_URL) + .messageConverters(converters -> { + converters.removeIf(c -> c instanceof MappingJackson2HttpMessageConverter); + converters.add(converter); + }) + .build(); + } +} diff --git a/search-service/src/main/java/com/truthlens/search/config/OpenApiConfig.java b/search-service/src/main/java/com/truthlens/search/config/OpenApiConfig.java new file mode 100644 index 0000000..6d2788f --- /dev/null +++ b/search-service/src/main/java/com/truthlens/search/config/OpenApiConfig.java @@ -0,0 +1,24 @@ +package com.truthlens.search.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.annotations.servers.Server; +import org.springframework.context.annotation.Configuration; + +@Configuration +@OpenAPIDefinition( + info = @Info(title = "TruthLens Search API", version = "v1"), + security = @SecurityRequirement(name = "bearerAuth"), + servers = {@Server(url = "/", description = "Default Server URL")} +) +@SecurityScheme( + name = "bearerAuth", + type = SecuritySchemeType.HTTP, + bearerFormat = "JWT", + scheme = "bearer" +) +public class OpenApiConfig { +} diff --git a/search-service/src/main/java/com/truthlens/search/config/WikipediaClientConfig.java b/search-service/src/main/java/com/truthlens/search/config/WikipediaClientConfig.java new file mode 100644 index 0000000..f0785b6 --- /dev/null +++ b/search-service/src/main/java/com/truthlens/search/config/WikipediaClientConfig.java @@ -0,0 +1,21 @@ +package com.truthlens.search.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; +import org.springframework.web.client.RestClient; + +@Configuration +@EnableRetry +public class WikipediaClientConfig { + + private static final String WIKIPEDIA_API_BASE_URL = "https://en.wikipedia.org/w/api.php"; + + @Bean + public RestClient wikipediaRestClient() { + return RestClient.builder() + .baseUrl(WIKIPEDIA_API_BASE_URL) + .defaultHeader("User-Agent", "TruthLens/1.0 (contact@example.com)") + .build(); + } +} diff --git a/search-service/src/main/java/com/truthlens/search/controller/SearchController.java b/search-service/src/main/java/com/truthlens/search/controller/SearchController.java new file mode 100644 index 0000000..db51fb4 --- /dev/null +++ b/search-service/src/main/java/com/truthlens/search/controller/SearchController.java @@ -0,0 +1,40 @@ +package com.truthlens.search.controller; + +import com.truthlens.search.model.SearchExecutionRequest; +import com.truthlens.search.model.SearchExecutionResponse; +import com.truthlens.search.service.WebSearchService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/internal/v1") +public class SearchController { + + private static final Logger log = LoggerFactory.getLogger(SearchController.class); + + private final WebSearchService webSearchService; + + public SearchController(WebSearchService webSearchService) { + this.webSearchService = webSearchService; + } + + @PostMapping("/search:execute") + public ResponseEntity executeSearch( + @RequestBody SearchExecutionRequest request) { + + log.info("Received search request with {} queries, maxResultsPerQuery={}", + request.queries() != null ? request.queries().size() : 0, + request.maxResultsPerQuery()); + + SearchExecutionResponse response = webSearchService.executeSearch(request); + + log.info("Returning {} search results", response.results().size()); + + return ResponseEntity.ok(response); + } +} diff --git a/search-service/src/main/java/com/truthlens/search/exception/ExternalSearchException.java b/search-service/src/main/java/com/truthlens/search/exception/ExternalSearchException.java new file mode 100644 index 0000000..c69c6c1 --- /dev/null +++ b/search-service/src/main/java/com/truthlens/search/exception/ExternalSearchException.java @@ -0,0 +1,16 @@ +package com.truthlens.search.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE) +public class ExternalSearchException extends RuntimeException { + + public ExternalSearchException(String message) { + super(message); + } + + public ExternalSearchException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/search-service/src/main/java/com/truthlens/search/exception/GlobalExceptionHandler.java b/search-service/src/main/java/com/truthlens/search/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..15d5e11 --- /dev/null +++ b/search-service/src/main/java/com/truthlens/search/exception/GlobalExceptionHandler.java @@ -0,0 +1,38 @@ +package com.truthlens.search.exception; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.Instant; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(ExternalSearchException.class) + public ResponseEntity> handleExternalSearchException(ExternalSearchException ex) { + log.error("External search API failure: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body(Map.of( + "error", "EXTERNAL_SEARCH_UNAVAILABLE", + "message", ex.getMessage(), + "timestamp", Instant.now().toString() + )); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleBadRequest(IllegalArgumentException ex) { + return ResponseEntity.badRequest() + .body(Map.of( + "error", "BAD_REQUEST", + "message", ex.getMessage(), + "timestamp", Instant.now().toString() + )); + } +} diff --git a/search-service/src/main/java/com/truthlens/search/model/DuckDuckGoApiResponse.java b/search-service/src/main/java/com/truthlens/search/model/DuckDuckGoApiResponse.java new file mode 100644 index 0000000..ae3ba62 --- /dev/null +++ b/search-service/src/main/java/com/truthlens/search/model/DuckDuckGoApiResponse.java @@ -0,0 +1,25 @@ +package com.truthlens.search.model; + +import java.util.List; + +public record DuckDuckGoApiResponse( + String Abstract, + String AbstractText, + String AbstractSource, + String AbstractURL, + String Heading, + String Answer, + String AnswerType, + String Definition, + String DefinitionSource, + String DefinitionURL, + List RelatedTopics +) { + public record RelatedTopic( + String Text, + String FirstURL, + String Result, + List Topics, + String Name + ) {} +} diff --git a/search-service/src/main/java/com/truthlens/search/model/SearchExecutionRequest.java b/search-service/src/main/java/com/truthlens/search/model/SearchExecutionRequest.java new file mode 100644 index 0000000..dddeca5 --- /dev/null +++ b/search-service/src/main/java/com/truthlens/search/model/SearchExecutionRequest.java @@ -0,0 +1,9 @@ +package com.truthlens.search.model; + +import java.util.List; + +public record SearchExecutionRequest( + List queries, + int maxResultsPerQuery +) { +} diff --git a/search-service/src/main/java/com/truthlens/search/model/SearchExecutionResponse.java b/search-service/src/main/java/com/truthlens/search/model/SearchExecutionResponse.java new file mode 100644 index 0000000..c808b49 --- /dev/null +++ b/search-service/src/main/java/com/truthlens/search/model/SearchExecutionResponse.java @@ -0,0 +1,9 @@ +package com.truthlens.search.model; + +import java.util.List; + + +public record SearchExecutionResponse( + List results +) { +} diff --git a/search-service/src/main/java/com/truthlens/search/model/SearchResultItem.java b/search-service/src/main/java/com/truthlens/search/model/SearchResultItem.java new file mode 100644 index 0000000..b2a7006 --- /dev/null +++ b/search-service/src/main/java/com/truthlens/search/model/SearchResultItem.java @@ -0,0 +1,8 @@ +package com.truthlens.search.model; + +public record SearchResultItem( + String title, + String url, + String snippet +) { +} diff --git a/search-service/src/main/java/com/truthlens/search/model/WikipediaApiResponse.java b/search-service/src/main/java/com/truthlens/search/model/WikipediaApiResponse.java new file mode 100644 index 0000000..e22a6e9 --- /dev/null +++ b/search-service/src/main/java/com/truthlens/search/model/WikipediaApiResponse.java @@ -0,0 +1,8 @@ +package com.truthlens.search.model; + +import java.util.List; + +public record WikipediaApiResponse(Query query) { + public record Query(List search) {} + public record SearchItem(String title, String snippet) {} +} diff --git a/search-service/src/main/java/com/truthlens/search/service/WebSearchService.java b/search-service/src/main/java/com/truthlens/search/service/WebSearchService.java new file mode 100644 index 0000000..17346fd --- /dev/null +++ b/search-service/src/main/java/com/truthlens/search/service/WebSearchService.java @@ -0,0 +1,222 @@ +package com.truthlens.search.service; + +import com.truthlens.search.exception.ExternalSearchException; +import com.truthlens.search.model.DuckDuckGoApiResponse; +import com.truthlens.search.model.SearchExecutionRequest; +import com.truthlens.search.model.SearchExecutionResponse; +import com.truthlens.search.model.SearchResultItem; +import com.truthlens.search.model.WikipediaApiResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.RestClient; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class WebSearchService { + + private static final Logger log = LoggerFactory.getLogger(WebSearchService.class); + + private final RestClient duckDuckGoRestClient; + private final RestClient wikipediaRestClient; + + public WebSearchService(RestClient duckDuckGoRestClient, RestClient wikipediaRestClient) { + this.duckDuckGoRestClient = duckDuckGoRestClient; + this.wikipediaRestClient = wikipediaRestClient; + } + + public SearchExecutionResponse executeSearch(SearchExecutionRequest request) { + if (request.queries() == null || request.queries().isEmpty()) { + throw new IllegalArgumentException("At least one search query is required"); + } + + int maxPerQuery = request.maxResultsPerQuery() > 0 ? request.maxResultsPerQuery() : 3; + + Map deduplicatedResults = new LinkedHashMap<>(); + + for (String query : request.queries()) { + log.info("Searching DuckDuckGo for: \"{}\"", query); + List queryResults = searchDuckDuckGo(query, maxPerQuery); + + if (queryResults.isEmpty()) { + log.info("DuckDuckGo returned empty results for \"{}\". Falling back to Wikipedia.", query); + queryResults = searchWikipedia(query, maxPerQuery); + } + + if (queryResults.isEmpty()) { + queryResults = List.of(createFallbackResult(query)); + } + + for (SearchResultItem item : queryResults) { + deduplicatedResults.putIfAbsent(item.url(), item); + } + } + + List results = new ArrayList<>(deduplicatedResults.values()); + log.info("Total deduplicated results: {}", results.size()); + + return new SearchExecutionResponse(results); + } + + @Retryable( + retryFor = ExternalSearchException.class, + maxAttempts = 3, + backoff = @Backoff(delay = 1000, multiplier = 2.0) + ) + public List searchDuckDuckGo(String query, int maxResults) { + try { + DuckDuckGoApiResponse response = duckDuckGoRestClient.get() + .uri(uriBuilder -> uriBuilder + .queryParam("q", query) + .queryParam("format", "json") + .queryParam("no_html", "1") + .queryParam("skip_disambig", "1") + .build()) + .retrieve() + .body(DuckDuckGoApiResponse.class); + + if (response == null) { + return List.of(); + } + + return extractDuckDuckGoResults(response, maxResults); + + } catch (HttpClientErrorException.TooManyRequests ex) { + throw new ExternalSearchException("DuckDuckGo rate limit exceeded (429) for query: " + query, ex); + } catch (HttpServerErrorException.ServiceUnavailable ex) { + throw new ExternalSearchException("DuckDuckGo service unavailable (503) for query: " + query, ex); + } catch (HttpServerErrorException | HttpClientErrorException ex) { + throw new ExternalSearchException("DuckDuckGo API error (%d) for query: %s".formatted(ex.getStatusCode().value(), query), ex); + } catch (Exception ex) { + log.error("Unexpected error calling DuckDuckGo for query: \"{}\"", query, ex); + throw new ExternalSearchException("Unexpected error calling DuckDuckGo for query: " + query, ex); + } + } + + @Retryable( + retryFor = ExternalSearchException.class, + maxAttempts = 3, + backoff = @Backoff(delay = 1000, multiplier = 2.0) + ) + public List searchWikipedia(String query, int maxResults) { + try { + WikipediaApiResponse response = wikipediaRestClient.get() + .uri(uriBuilder -> uriBuilder + .queryParam("action", "query") + .queryParam("list", "search") + .queryParam("srsearch", query) + .queryParam("utf8", "") + .queryParam("format", "json") + .build()) + .retrieve() + .body(WikipediaApiResponse.class); + + if (response == null || response.query() == null || response.query().search() == null) { + return List.of(); + } + + return extractWikipediaResults(response, maxResults); + + } catch (HttpClientErrorException.TooManyRequests ex) { + throw new ExternalSearchException("Wikipedia rate limit exceeded (429) for query: " + query, ex); + } catch (HttpServerErrorException.ServiceUnavailable ex) { + throw new ExternalSearchException("Wikipedia service unavailable (503) for query: " + query, ex); + } catch (HttpServerErrorException | HttpClientErrorException ex) { + throw new ExternalSearchException("Wikipedia API error (%d) for query: %s".formatted(ex.getStatusCode().value(), query), ex); + } catch (Exception ex) { + log.error("Unexpected error calling Wikipedia for query: \"{}\"", query, ex); + throw new ExternalSearchException("Unexpected error calling Wikipedia for query: " + query, ex); + } + } + + private List extractDuckDuckGoResults(DuckDuckGoApiResponse response, int maxResults) { + List items = new ArrayList<>(); + + if (response.AbstractText() != null && !response.AbstractText().isBlank()) { + items.add(new SearchResultItem( + response.Heading() != null ? response.Heading() : "Abstract", + response.AbstractURL() != null ? response.AbstractURL() : "", + response.AbstractText() + )); + } + + if (response.Answer() != null && !response.Answer().isBlank()) { + items.add(new SearchResultItem("Direct Answer", "", response.Answer())); + } + + if (response.Definition() != null && !response.Definition().isBlank()) { + items.add(new SearchResultItem( + response.DefinitionSource() != null ? response.DefinitionSource() : "Definition", + response.DefinitionURL() != null ? response.DefinitionURL() : "", + response.Definition() + )); + } + + if (response.RelatedTopics() != null) { + for (DuckDuckGoApiResponse.RelatedTopic topic : response.RelatedTopics()) { + if (items.size() >= maxResults) break; + + if (topic.Text() != null && !topic.Text().isBlank() && topic.FirstURL() != null && !topic.FirstURL().isBlank()) { + items.add(new SearchResultItem(extractTitle(topic.Text()), topic.FirstURL(), topic.Text())); + } + + if (topic.Topics() != null) { + for (DuckDuckGoApiResponse.RelatedTopic nested : topic.Topics()) { + if (items.size() >= maxResults) break; + + if (nested.Text() != null && !nested.Text().isBlank() && nested.FirstURL() != null && !nested.FirstURL().isBlank()) { + items.add(new SearchResultItem(extractTitle(nested.Text()), nested.FirstURL(), nested.Text())); + } + } + } + } + } + + return items.size() > maxResults ? items.subList(0, maxResults) : items; + } + + private List extractWikipediaResults(WikipediaApiResponse response, int maxResults) { + List items = new ArrayList<>(); + + for (WikipediaApiResponse.SearchItem item : response.query().search()) { + if (items.size() >= maxResults) break; + + String title = item.title(); + String snippetHtml = item.snippet(); + String snippetText = snippetHtml != null ? snippetHtml.replaceAll("<[^>]*>", "") : ""; + String url = "https://en.wikipedia.org/wiki/" + URLEncoder.encode(title.replace(" ", "_"), StandardCharsets.UTF_8); + + items.add(new SearchResultItem(title, url, snippetText)); + } + + return items; + } + + private SearchResultItem createFallbackResult(String query) { + String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8); + return new SearchResultItem( + "Search Results on DuckDuckGo", + "https://duckduckgo.com/?q=" + encodedQuery, + "No direct API summary available. Click the link to see full DuckDuckGo search results." + ); + } + + private String extractTitle(String text) { + if (text == null) return "Search Result"; + int dashIndex = text.indexOf(" - "); + if (dashIndex > 0 && dashIndex < 80) { + return text.substring(0, dashIndex); + } + return text.length() > 60 ? text.substring(0, 60) + "..." : text; + } +} diff --git a/search-service/src/main/resources/application.yml b/search-service/src/main/resources/application.yml new file mode 100644 index 0000000..9edea42 --- /dev/null +++ b/search-service/src/main/resources/application.yml @@ -0,0 +1,24 @@ +spring: + application: + name: truthlens-search-service + config: + import: "optional:configserver:http://truthlens-config-server:8888" + cloud: + config: + fail-fast: true + retry: + initial-interval: 2000 + max-interval: 10000 + multiplier: 1.5 + max-attempts: 15 + threads: + virtual: + enabled: true + +server: + port: 8080 + +eureka: + client: + serviceUrl: + defaultZone: http://truthlens-discovery-server:8761/eureka/ diff --git a/search-service/src/test/java/com/truthlens/search/SearchServiceApplicationTest.java b/search-service/src/test/java/com/truthlens/search/SearchServiceApplicationTest.java new file mode 100644 index 0000000..c71bb6f --- /dev/null +++ b/search-service/src/test/java/com/truthlens/search/SearchServiceApplicationTest.java @@ -0,0 +1,17 @@ +package com.truthlens.search; + +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.boot.SpringApplication; + +class SearchServiceApplicationTest { + + @Test + void main() { + try (MockedStatic mocked = Mockito.mockStatic(SpringApplication.class)) { + SearchServiceApplication.main(new String[]{}); + mocked.verify(() -> SpringApplication.run(SearchServiceApplication.class, new String[]{})); + } + } +} diff --git a/search-service/src/test/java/com/truthlens/search/config/ClientConfigTest.java b/search-service/src/test/java/com/truthlens/search/config/ClientConfigTest.java new file mode 100644 index 0000000..ce68702 --- /dev/null +++ b/search-service/src/test/java/com/truthlens/search/config/ClientConfigTest.java @@ -0,0 +1,23 @@ +package com.truthlens.search.config; + +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; + +class ClientConfigTest { + + @Test + void testDuckDuckGoClientConfig() { + DuckDuckGoClientConfig config = new DuckDuckGoClientConfig(); + RestClient client = config.duckDuckGoRestClient(); + assertThat(client).isNotNull(); + } + + @Test + void testWikipediaClientConfig() { + WikipediaClientConfig config = new WikipediaClientConfig(); + RestClient client = config.wikipediaRestClient(); + assertThat(client).isNotNull(); + } +} diff --git a/search-service/src/test/java/com/truthlens/search/controller/SearchControllerTest.java b/search-service/src/test/java/com/truthlens/search/controller/SearchControllerTest.java new file mode 100644 index 0000000..81e3c1f --- /dev/null +++ b/search-service/src/test/java/com/truthlens/search/controller/SearchControllerTest.java @@ -0,0 +1,158 @@ +package com.truthlens.search.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.truthlens.search.model.SearchExecutionRequest; +import com.truthlens.search.model.SearchExecutionResponse; +import com.truthlens.search.model.SearchResultItem; +import com.truthlens.search.service.WebSearchService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(SearchController.class) +class SearchControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private WebSearchService webSearchService; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @DisplayName("POST /api/internal/v1/search:execute — should return 200 with results") + void shouldReturnResults() throws Exception { + var expectedResults = List.of( + new SearchResultItem( + "Test Article", + "https://example.com/article", + "This is a test article snippet" + ) + ); + + when(webSearchService.executeSearch(any(SearchExecutionRequest.class))) + .thenReturn(new SearchExecutionResponse(expectedResults)); + + var requestBody = new SearchExecutionRequest( + List.of("test query"), + 3 + ); + + mockMvc.perform(post("/api/internal/v1/search:execute") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestBody))) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.results").isArray()) + .andExpect(jsonPath("$.results.length()").value(1)) + .andExpect(jsonPath("$.results[0].title").value("Test Article")) + .andExpect(jsonPath("$.results[0].url").value("https://example.com/article")) + .andExpect(jsonPath("$.results[0].snippet").value("This is a test article snippet")); + } + + @Test + @DisplayName("POST /api/internal/v1/search:execute — should return 200 with empty results") + void shouldReturnEmptyResults() throws Exception { + when(webSearchService.executeSearch(any(SearchExecutionRequest.class))) + .thenReturn(new SearchExecutionResponse(List.of())); + + var requestBody = new SearchExecutionRequest( + List.of("obscure query"), + 3 + ); + + mockMvc.perform(post("/api/internal/v1/search:execute") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestBody))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results").isArray()) + .andExpect(jsonPath("$.results.length()").value(0)); + } + + @Test + @DisplayName("POST /api/internal/v1/search:execute - should handle null queries list in logging gracefully") + void shouldHandleNullQueries() throws Exception { + when(webSearchService.executeSearch(any(SearchExecutionRequest.class))) + .thenReturn(new SearchExecutionResponse(List.of())); + + var requestBody = new SearchExecutionRequest(null, 3); + + mockMvc.perform(post("/api/internal/v1/search:execute") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestBody))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("POST /api/internal/v1/search:execute — should return 400 on bad request") + void shouldReturn400OnBadRequest() throws Exception { + when(webSearchService.executeSearch(any(SearchExecutionRequest.class))) + .thenThrow(new IllegalArgumentException("At least one search query is required")); + + var requestBody = new SearchExecutionRequest(List.of(), 3); + + mockMvc.perform(post("/api/internal/v1/search:execute") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestBody))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("BAD_REQUEST")) + .andExpect(jsonPath("$.message").value("At least one search query is required")); + } + + @Test + @DisplayName("POST /api/internal/v1/search:execute — should return 503 on external search failure") + void shouldReturn503OnExternalSearchFailure() throws Exception { + when(webSearchService.executeSearch(any(SearchExecutionRequest.class))) + .thenThrow(new com.truthlens.search.exception.ExternalSearchException( + "DuckDuckGo rate limit exceeded (429)")); + + var requestBody = new SearchExecutionRequest(List.of("query"), 3); + + mockMvc.perform(post("/api/internal/v1/search:execute") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestBody))) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.error").value("EXTERNAL_SEARCH_UNAVAILABLE")) + .andExpect(jsonPath("$.message").exists()) + .andExpect(jsonPath("$.timestamp").exists()); + } + + @Test + @DisplayName("POST /api/internal/v1/search:execute — should return multiple results in correct JSON format") + void shouldReturnMultipleResultsInCorrectFormat() throws Exception { + var results = List.of( + new SearchResultItem("Article 1", "https://example.com/1", "Snippet 1"), + new SearchResultItem("Article 2", "https://example.com/2", "Snippet 2"), + new SearchResultItem("Article 3", "https://example.com/3", "Snippet 3") + ); + + when(webSearchService.executeSearch(any(SearchExecutionRequest.class))) + .thenReturn(new SearchExecutionResponse(results)); + + var requestBody = new SearchExecutionRequest(List.of("query 1", "query 2"), 3); + + mockMvc.perform(post("/api/internal/v1/search:execute") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestBody))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results.length()").value(3)) + .andExpect(jsonPath("$.results[0].title").value("Article 1")) + .andExpect(jsonPath("$.results[1].title").value("Article 2")) + .andExpect(jsonPath("$.results[2].title").value("Article 3")) + .andExpect(jsonPath("$.results[0].url").value("https://example.com/1")) + .andExpect(jsonPath("$.results[1].snippet").value("Snippet 2")); + } +} diff --git a/search-service/src/test/java/com/truthlens/search/model/WikipediaApiResponseTest.java b/search-service/src/test/java/com/truthlens/search/model/WikipediaApiResponseTest.java new file mode 100644 index 0000000..df41957 --- /dev/null +++ b/search-service/src/test/java/com/truthlens/search/model/WikipediaApiResponseTest.java @@ -0,0 +1,38 @@ +package com.truthlens.search.model; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class WikipediaApiResponseTest { + + @Test + void testWikipediaApiResponseRecords() { + WikipediaApiResponse.SearchItem item1 = new WikipediaApiResponse.SearchItem("title1", "snippet1"); + WikipediaApiResponse.SearchItem item2 = new WikipediaApiResponse.SearchItem("title1", "snippet1"); + + assertThat(item1.title()).isEqualTo("title1"); + assertThat(item1.snippet()).isEqualTo("snippet1"); + assertThat(item1).isEqualTo(item2); + assertThat(item1.hashCode()).isEqualTo(item2.hashCode()); + assertThat(item1.toString()).contains("title1"); + + WikipediaApiResponse.Query query1 = new WikipediaApiResponse.Query(List.of(item1)); + WikipediaApiResponse.Query query2 = new WikipediaApiResponse.Query(List.of(item2)); + + assertThat(query1.search()).hasSize(1); + assertThat(query1).isEqualTo(query2); + assertThat(query1.hashCode()).isEqualTo(query2.hashCode()); + assertThat(query1.toString()).contains("search"); + + WikipediaApiResponse response1 = new WikipediaApiResponse(query1); + WikipediaApiResponse response2 = new WikipediaApiResponse(query2); + + assertThat(response1.query()).isEqualTo(query1); + assertThat(response1).isEqualTo(response2); + assertThat(response1.hashCode()).isEqualTo(response2.hashCode()); + assertThat(response1.toString()).contains("query"); + } +} diff --git a/search-service/src/test/java/com/truthlens/search/service/WebSearchServiceEdgeCasesTest.java b/search-service/src/test/java/com/truthlens/search/service/WebSearchServiceEdgeCasesTest.java new file mode 100644 index 0000000..a236f91 --- /dev/null +++ b/search-service/src/test/java/com/truthlens/search/service/WebSearchServiceEdgeCasesTest.java @@ -0,0 +1,292 @@ +package com.truthlens.search.service; + +import com.truthlens.search.exception.ExternalSearchException; +import com.truthlens.search.model.SearchExecutionRequest; +import com.truthlens.search.model.WikipediaApiResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.RestClient; + +import java.util.List; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class WebSearchServiceEdgeCasesTest { + + @Mock + private RestClient duckDuckGoRestClient; + + @Mock + private RestClient wikipediaRestClient; + + @Mock + private RestClient.RequestHeadersUriSpec requestHeadersUriSpec; + + @Mock + private RestClient.RequestHeadersSpec requestHeadersSpec; + + @Mock + private RestClient.ResponseSpec responseSpec; + + private WebSearchService webSearchService; + + @SuppressWarnings("unchecked") + @BeforeEach + void setUp() { + webSearchService = new WebSearchService(duckDuckGoRestClient, wikipediaRestClient); + lenient().when(requestHeadersUriSpec.uri(any(Function.class))).thenAnswer(invocation -> { + Function function = invocation.getArgument(0); + function.apply(new org.springframework.web.util.DefaultUriBuilderFactory().builder()); + return requestHeadersSpec; + }); + } + + @Test + void testNullQueries() { + SearchExecutionRequest request = new SearchExecutionRequest(null, 3); + assertThatThrownBy(() -> webSearchService.executeSearch(request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("At least one search query is required"); + } + + @Test + void testEmptyQueries() { + SearchExecutionRequest request = new SearchExecutionRequest(List.of(), 3); + assertThatThrownBy(() -> webSearchService.executeSearch(request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("At least one search query is required"); + } + + @Test + @SuppressWarnings("unchecked") + void testWikipediaSuccess() { + lenient().when(duckDuckGoRestClient.get()).thenReturn((RestClient.RequestHeadersUriSpec) requestHeadersUriSpec); + lenient().when(wikipediaRestClient.get()).thenReturn((RestClient.RequestHeadersUriSpec) requestHeadersUriSpec); + lenient().when(requestHeadersUriSpec.uri(any(Function.class))).thenReturn((RestClient.RequestHeadersSpec) requestHeadersSpec); + lenient().when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); + + WikipediaApiResponse response = new WikipediaApiResponse( + new WikipediaApiResponse.Query(List.of( + new WikipediaApiResponse.SearchItem("Wiki Title", "Snippet") + )) + ); + when(responseSpec.body(WikipediaApiResponse.class)).thenReturn(response); + when(responseSpec.body(com.truthlens.search.model.DuckDuckGoApiResponse.class)).thenReturn(null); + + SearchExecutionRequest request = new SearchExecutionRequest(List.of("query"), 3); + var results = webSearchService.executeSearch(request).results(); + assertThat(results).hasSize(1); + assertThat(results.get(0).title()).isEqualTo("Wiki Title"); + assertThat(results.get(0).snippet()).isEqualTo("Snippet"); + } + + @Test + @SuppressWarnings("unchecked") + void testWikipediaExceptions() { + lenient().when(wikipediaRestClient.get()).thenReturn((RestClient.RequestHeadersUriSpec) requestHeadersUriSpec); + lenient().when(requestHeadersUriSpec.uri(any(Function.class))).thenReturn((RestClient.RequestHeadersSpec) requestHeadersSpec); + lenient().when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); + + doThrow(HttpClientErrorException.create(HttpStatus.TOO_MANY_REQUESTS, "429", null, null, null)) + .when(responseSpec).body(WikipediaApiResponse.class); + assertThatThrownBy(() -> webSearchService.searchWikipedia("query", 3)) + .isInstanceOf(ExternalSearchException.class) + .hasMessageContaining("429"); + + doThrow(HttpServerErrorException.create(HttpStatus.SERVICE_UNAVAILABLE, "503", null, null, null)) + .when(responseSpec).body(WikipediaApiResponse.class); + assertThatThrownBy(() -> webSearchService.searchWikipedia("query", 3)) + .isInstanceOf(ExternalSearchException.class) + .hasMessageContaining("503"); + + doThrow(HttpServerErrorException.create(HttpStatus.INTERNAL_SERVER_ERROR, "500", null, null, null)) + .when(responseSpec).body(WikipediaApiResponse.class); + assertThatThrownBy(() -> webSearchService.searchWikipedia("query", 3)) + .isInstanceOf(ExternalSearchException.class) + .hasMessageContaining("500"); + + doThrow(new RuntimeException("error")) + .when(responseSpec).body(WikipediaApiResponse.class); + assertThatThrownBy(() -> webSearchService.searchWikipedia("query", 3)) + .isInstanceOf(ExternalSearchException.class) + .hasMessageContaining("Unexpected error"); + } + + @Test + @SuppressWarnings("unchecked") + void testDuckDuckGoExceptions() { + lenient().when(duckDuckGoRestClient.get()).thenReturn((RestClient.RequestHeadersUriSpec) requestHeadersUriSpec); + lenient().when(requestHeadersUriSpec.uri(any(Function.class))).thenReturn((RestClient.RequestHeadersSpec) requestHeadersSpec); + lenient().when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); + + doThrow(HttpClientErrorException.create(HttpStatus.TOO_MANY_REQUESTS, "429", null, null, null)) + .when(responseSpec).body(com.truthlens.search.model.DuckDuckGoApiResponse.class); + assertThatThrownBy(() -> webSearchService.searchDuckDuckGo("query", 3)) + .isInstanceOf(ExternalSearchException.class) + .hasMessageContaining("429"); + + doThrow(HttpServerErrorException.create(HttpStatus.SERVICE_UNAVAILABLE, "503", null, null, null)) + .when(responseSpec).body(com.truthlens.search.model.DuckDuckGoApiResponse.class); + assertThatThrownBy(() -> webSearchService.searchDuckDuckGo("query", 3)) + .isInstanceOf(ExternalSearchException.class) + .hasMessageContaining("503"); + } + + @Test + @SuppressWarnings("unchecked") + void testWikipediaBranchCoverage() { + lenient().when(duckDuckGoRestClient.get()).thenReturn((RestClient.RequestHeadersUriSpec) requestHeadersUriSpec); + lenient().when(wikipediaRestClient.get()).thenReturn((RestClient.RequestHeadersUriSpec) requestHeadersUriSpec); + lenient().when(requestHeadersUriSpec.uri(any(Function.class))).thenAnswer(invocation -> { + Function function = invocation.getArgument(0); + function.apply(new org.springframework.web.util.DefaultUriBuilderFactory().builder()); + return requestHeadersSpec; + }); + lenient().when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); + + when(responseSpec.body(com.truthlens.search.model.DuckDuckGoApiResponse.class)).thenReturn(null); + + WikipediaApiResponse responseNoQuery = new WikipediaApiResponse(null); + when(responseSpec.body(WikipediaApiResponse.class)).thenReturn(responseNoQuery); + var res1 = webSearchService.executeSearch(new SearchExecutionRequest(List.of("query"), 3)).results(); + assertThat(res1).hasSize(1); + assertThat(res1.get(0).title()).isEqualTo("Search Results on DuckDuckGo"); + + WikipediaApiResponse responseNoSearch = new WikipediaApiResponse(new WikipediaApiResponse.Query(null)); + when(responseSpec.body(WikipediaApiResponse.class)).thenReturn(responseNoSearch); + var res2 = webSearchService.executeSearch(new SearchExecutionRequest(List.of("query"), 3)).results(); + assertThat(res2).hasSize(1); + assertThat(res2.get(0).title()).isEqualTo("Search Results on DuckDuckGo"); + + WikipediaApiResponse responseNullSnippet = new WikipediaApiResponse(new WikipediaApiResponse.Query(List.of( + new WikipediaApiResponse.SearchItem("Title", null) + ))); + when(responseSpec.body(WikipediaApiResponse.class)).thenReturn(responseNullSnippet); + var res = webSearchService.executeSearch(new SearchExecutionRequest(List.of("query"), 3)).results(); + assertThat(res.get(0).snippet()).isEmpty(); + + WikipediaApiResponse responseBreak = new WikipediaApiResponse(new WikipediaApiResponse.Query(List.of( + new WikipediaApiResponse.SearchItem("Title1", "snippet1"), + new WikipediaApiResponse.SearchItem("Title2", "snippet2") + ))); + when(responseSpec.body(WikipediaApiResponse.class)).thenReturn(responseBreak); + var resBreak = webSearchService.executeSearch(new SearchExecutionRequest(List.of("query"), 1)).results(); + assertThat(resBreak).hasSize(1); + } + + private void setupMockClients() { + lenient().when(duckDuckGoRestClient.get()).thenReturn((RestClient.RequestHeadersUriSpec) requestHeadersUriSpec); + lenient().when(wikipediaRestClient.get()).thenReturn((RestClient.RequestHeadersUriSpec) requestHeadersUriSpec); + lenient().when(requestHeadersUriSpec.uri(any(Function.class))).thenAnswer(invocation -> { + Function function = invocation.getArgument(0); + function.apply(new org.springframework.web.util.DefaultUriBuilderFactory().builder()); + return requestHeadersSpec; + }); + lenient().when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); + } + + @Test + @SuppressWarnings("unchecked") + void testDuckDuckGoBlank() { + setupMockClients(); + com.truthlens.search.model.DuckDuckGoApiResponse ddgBlank = new com.truthlens.search.model.DuckDuckGoApiResponse( + null, "", null, null, "heading", "", null, "", null, null, null + ); + when(responseSpec.body(com.truthlens.search.model.DuckDuckGoApiResponse.class)).thenReturn(ddgBlank); + when(responseSpec.body(WikipediaApiResponse.class)).thenReturn(null); + var resBlank = webSearchService.executeSearch(new SearchExecutionRequest(List.of("query"), 3)).results(); + assertThat(resBlank).hasSize(1); + assertThat(resBlank.get(0).title()).isEqualTo("Search Results on DuckDuckGo"); + } + + @Test + @SuppressWarnings("unchecked") + void testDuckDuckGoTopics() { + setupMockClients(); + com.truthlens.search.model.DuckDuckGoApiResponse.RelatedTopic blankTopic = + new com.truthlens.search.model.DuckDuckGoApiResponse.RelatedTopic(null, "url", null, null, null); + com.truthlens.search.model.DuckDuckGoApiResponse.RelatedTopic nestedTopicBlank = + new com.truthlens.search.model.DuckDuckGoApiResponse.RelatedTopic(" ", "url", null, null, null); + com.truthlens.search.model.DuckDuckGoApiResponse.RelatedTopic nestedTopicValid = + new com.truthlens.search.model.DuckDuckGoApiResponse.RelatedTopic("valid", "validUrl", null, null, null); + com.truthlens.search.model.DuckDuckGoApiResponse.RelatedTopic topicWithTopics = + new com.truthlens.search.model.DuckDuckGoApiResponse.RelatedTopic(null, null, null, List.of(nestedTopicBlank, nestedTopicValid), null); + + com.truthlens.search.model.DuckDuckGoApiResponse ddgTopics = new com.truthlens.search.model.DuckDuckGoApiResponse( + null, null, null, null, null, null, null, null, null, null, List.of(blankTopic, topicWithTopics) + ); + when(responseSpec.body(com.truthlens.search.model.DuckDuckGoApiResponse.class)).thenReturn(ddgTopics); + var resTopics = webSearchService.executeSearch(new SearchExecutionRequest(List.of("query"), 3)).results(); + assertThat(resTopics).hasSize(1); + assertThat(resTopics.get(0).title()).isEqualTo("valid"); + } + + @Test + @SuppressWarnings("unchecked") + void testDuckDuckGoTopicsBreak() { + setupMockClients(); + com.truthlens.search.model.DuckDuckGoApiResponse.RelatedTopic valid1 = + new com.truthlens.search.model.DuckDuckGoApiResponse.RelatedTopic("t1", "u1", null, null, null); + com.truthlens.search.model.DuckDuckGoApiResponse.RelatedTopic valid2 = + new com.truthlens.search.model.DuckDuckGoApiResponse.RelatedTopic("t2", "u2", null, null, null); + com.truthlens.search.model.DuckDuckGoApiResponse.RelatedTopic topicWithTopics2 = + new com.truthlens.search.model.DuckDuckGoApiResponse.RelatedTopic(null, null, null, List.of(valid1, valid2), null); + + com.truthlens.search.model.DuckDuckGoApiResponse ddgTopicsBreak = new com.truthlens.search.model.DuckDuckGoApiResponse( + null, null, null, null, null, null, null, null, null, null, List.of(valid1, valid2, topicWithTopics2) + ); + when(responseSpec.body(com.truthlens.search.model.DuckDuckGoApiResponse.class)).thenReturn(ddgTopicsBreak); + var resTopicsBreak = webSearchService.executeSearch(new SearchExecutionRequest(List.of("query"), 1)).results(); + assertThat(resTopicsBreak).hasSize(1); + } + + @Test + @SuppressWarnings("unchecked") + void testDuckDuckGoNestedBreak() { + setupMockClients(); + com.truthlens.search.model.DuckDuckGoApiResponse.RelatedTopic valid1 = + new com.truthlens.search.model.DuckDuckGoApiResponse.RelatedTopic("t1", "u1", null, null, null); + com.truthlens.search.model.DuckDuckGoApiResponse.RelatedTopic valid2 = + new com.truthlens.search.model.DuckDuckGoApiResponse.RelatedTopic("t2", "u2", null, null, null); + com.truthlens.search.model.DuckDuckGoApiResponse.RelatedTopic topicWithTopics2 = + new com.truthlens.search.model.DuckDuckGoApiResponse.RelatedTopic(null, null, null, List.of(valid1, valid2), null); + + when(responseSpec.body(com.truthlens.search.model.DuckDuckGoApiResponse.class)).thenReturn( + new com.truthlens.search.model.DuckDuckGoApiResponse(null, null, null, null, null, null, null, null, null, null, List.of(topicWithTopics2)) + ); + var resNestedBreak = webSearchService.executeSearch(new SearchExecutionRequest(List.of("query"), 1)).results(); + assertThat(resNestedBreak).hasSize(1); + } + + @Test + @SuppressWarnings("unchecked") + void testExtractTitleNull() throws Exception { + java.lang.reflect.Method method = WebSearchService.class.getDeclaredMethod("extractTitle", String.class); + method.setAccessible(true); + String result = (String) method.invoke(webSearchService, (String) null); + assertThat(result).isEqualTo("Search Result"); + + String longText = "This is a very long text that will exceed the sixty characters limit easily and then get truncated."; + String longResult = (String) method.invoke(webSearchService, longText); + assertThat(longResult).endsWith("..."); + + String hyphenText = "Prefix - Suffix"; + String hyphenResult = (String) method.invoke(webSearchService, hyphenText); + assertThat(hyphenResult).isEqualTo("Prefix"); + + String hyphenLateText = "This prefix is extremely long and will go past sixty characters before it reaches the - dash"; + String hyphenLateResult = (String) method.invoke(webSearchService, hyphenLateText); + assertThat(hyphenLateResult).endsWith("..."); + } +} diff --git a/search-service/src/test/java/com/truthlens/search/service/WebSearchServiceTest.java b/search-service/src/test/java/com/truthlens/search/service/WebSearchServiceTest.java new file mode 100644 index 0000000..f984f63 --- /dev/null +++ b/search-service/src/test/java/com/truthlens/search/service/WebSearchServiceTest.java @@ -0,0 +1,520 @@ +package com.truthlens.search.service; + +import com.truthlens.search.exception.ExternalSearchException; +import com.truthlens.search.model.DuckDuckGoApiResponse; +import com.truthlens.search.model.SearchExecutionRequest; +import com.truthlens.search.model.SearchExecutionResponse; +import com.truthlens.search.model.SearchResultItem; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.RestClient; + +import java.util.List; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class WebSearchServiceTest { + + @Mock + private RestClient duckDuckGoRestClient; + + @Mock + private RestClient wikipediaRestClient; + + @Mock + private RestClient.RequestHeadersUriSpec requestHeadersUriSpec; + + @Mock + private RestClient.RequestHeadersSpec requestHeadersSpec; + + @Mock + private RestClient.ResponseSpec responseSpec; + + private WebSearchService webSearchService; + + @SuppressWarnings("unchecked") + @BeforeEach + void setUp() { + webSearchService = new WebSearchService(duckDuckGoRestClient, wikipediaRestClient); + lenient().when(duckDuckGoRestClient.get()).thenReturn((RestClient.RequestHeadersUriSpec) requestHeadersUriSpec); + lenient().when(wikipediaRestClient.get()).thenReturn((RestClient.RequestHeadersUriSpec) requestHeadersUriSpec); + lenient().when(requestHeadersUriSpec.uri(any(Function.class))).thenAnswer(invocation -> { + Function function = invocation.getArgument(0); + function.apply(new org.springframework.web.util.DefaultUriBuilderFactory().builder()); + return requestHeadersSpec; + }); + lenient().when(requestHeadersSpec.retrieve()).thenReturn(responseSpec); + } + + @Nested + @DisplayName("executeSearch — happy paths") + class HappyPaths { + + @Test + @DisplayName("should return results from abstract and related topics") + void shouldReturnAbstractAndRelatedTopics() { + var apiResponse = new DuckDuckGoApiResponse( + "Abstract text", + "Detailed abstract text about the topic", + "Wikipedia", + "https://en.wikipedia.org/wiki/Test", + "Test Topic", + null, null, null, null, null, + List.of( + new DuckDuckGoApiResponse.RelatedTopic( + "Related topic 1 - description of first result", + "https://example.com/1", + null, null, null + ), + new DuckDuckGoApiResponse.RelatedTopic( + "Related topic 2 - description of second result", + "https://example.com/2", + null, null, null + ) + ) + ); + + when(responseSpec.body(DuckDuckGoApiResponse.class)).thenReturn(apiResponse); + + var request = new SearchExecutionRequest(List.of("test query"), 5); + SearchExecutionResponse response = webSearchService.executeSearch(request); + + assertThat(response.results()).isNotEmpty(); + assertThat(response.results()).hasSizeGreaterThanOrEqualTo(1); + + SearchResultItem first = response.results().get(0); + assertThat(first.title()).isEqualTo("Test Topic"); + assertThat(first.url()).isEqualTo("https://en.wikipedia.org/wiki/Test"); + assertThat(first.snippet()).isEqualTo("Detailed abstract text about the topic"); + } + + @Test + @DisplayName("should deduplicate results by URL across multiple queries") + void shouldDeduplicateByUrl() { + var apiResponse = new DuckDuckGoApiResponse( + "Abstract", "Abstract text", "Source", + "https://example.com/shared", + "Heading", null, null, null, null, null, + List.of() + ); + + when(responseSpec.body(DuckDuckGoApiResponse.class)).thenReturn(apiResponse); + + var request = new SearchExecutionRequest( + List.of("query 1", "query 2"), + 3 + ); + + SearchExecutionResponse response = webSearchService.executeSearch(request); + + long distinctUrls = response.results().stream() + .map(SearchResultItem::url) + .distinct() + .count(); + + assertThat(distinctUrls).isEqualTo(response.results().size()); + } + + @Test + @DisplayName("should return fallback link when APIs return no data") + void shouldReturnEmptyOnNoData() { + var emptyResponse = new DuckDuckGoApiResponse( + null, null, null, null, null, + null, null, null, null, null, + List.of() + ); + + when(responseSpec.body(DuckDuckGoApiResponse.class)).thenReturn(emptyResponse); + + var request = new SearchExecutionRequest(List.of("obscure query"), 3); + SearchExecutionResponse response = webSearchService.executeSearch(request); + + assertThat(response.results()).hasSize(1); + assertThat(response.results().get(0).title()).isEqualTo("Search Results on DuckDuckGo"); + assertThat(response.results().get(0).url()).contains("obscure+query"); + } + + @Test + @DisplayName("should respect maxResultsPerQuery limit") + void shouldRespectMaxResults() { + var apiResponse = new DuckDuckGoApiResponse( + "Abstract", "Abstract text", "Source", + "https://example.com/abstract", "Heading", + "Direct answer", null, null, null, null, + List.of( + new DuckDuckGoApiResponse.RelatedTopic( + "Topic 1", "https://example.com/1", null, null, null), + new DuckDuckGoApiResponse.RelatedTopic( + "Topic 2", "https://example.com/2", null, null, null), + new DuckDuckGoApiResponse.RelatedTopic( + "Topic 3", "https://example.com/3", null, null, null) + ) + ); + + when(responseSpec.body(DuckDuckGoApiResponse.class)).thenReturn(apiResponse); + + var request = new SearchExecutionRequest(List.of("query"), 2); + SearchExecutionResponse response = webSearchService.executeSearch(request); + + assertThat(response.results()).hasSizeLessThanOrEqualTo(2); + } + + @Test + @DisplayName("should handle null response from APIs gracefully and return fallback") + void shouldHandleNullResponse() { + when(responseSpec.body(DuckDuckGoApiResponse.class)).thenReturn(null); + + var request = new SearchExecutionRequest(List.of("query"), 3); + SearchExecutionResponse response = webSearchService.executeSearch(request); + + assertThat(response.results()).hasSize(1); + assertThat(response.results().get(0).title()).isEqualTo("Search Results on DuckDuckGo"); + assertThat(response.results().get(0).url()).contains("query"); + } + } + + @Nested + @DisplayName("executeSearch — error handling") + class ErrorHandling { + + @Test + @DisplayName("should throw ExternalSearchException on 429 Too Many Requests") + void shouldThrowOn429() { + when(responseSpec.body(DuckDuckGoApiResponse.class)) + .thenThrow(HttpClientErrorException.create( + HttpStatus.TOO_MANY_REQUESTS, "Too Many Requests", + org.springframework.http.HttpHeaders.EMPTY, + new byte[0], null)); + + var request = new SearchExecutionRequest(List.of("query"), 3); + + assertThatThrownBy(() -> webSearchService.executeSearch(request)) + .isInstanceOf(ExternalSearchException.class) + .hasMessageContaining("429"); + } + + @Test + @DisplayName("should throw ExternalSearchException on 503 Service Unavailable") + void shouldThrowOn503() { + when(responseSpec.body(DuckDuckGoApiResponse.class)) + .thenThrow(HttpServerErrorException.create( + HttpStatus.SERVICE_UNAVAILABLE, "Service Unavailable", + org.springframework.http.HttpHeaders.EMPTY, + new byte[0], null)); + + var request = new SearchExecutionRequest(List.of("query"), 3); + + assertThatThrownBy(() -> webSearchService.executeSearch(request)) + .isInstanceOf(ExternalSearchException.class) + .hasMessageContaining("503"); + } + + @Test + @DisplayName("should throw IllegalArgumentException when queries list is empty") + void shouldThrowOnEmptyQueries() { + var request = new SearchExecutionRequest(List.of(), 3); + + assertThatThrownBy(() -> webSearchService.executeSearch(request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("At least one search query"); + } + + @Test + @DisplayName("should throw IllegalArgumentException when queries list is null") + void shouldThrowOnNullQueries() { + var request = new SearchExecutionRequest(null, 3); + + assertThatThrownBy(() -> webSearchService.executeSearch(request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("At least one search query"); + } + } + + @Nested + @DisplayName("extractResults — nested topics") + class NestedTopics { + + @Test + @DisplayName("should extract results from nested topic groups") + void shouldExtractNestedTopics() { + var nestedTopics = List.of( + new DuckDuckGoApiResponse.RelatedTopic( + "Nested 1", "https://example.com/nested1", null, null, null), + new DuckDuckGoApiResponse.RelatedTopic( + "Nested 2", "https://example.com/nested2", null, null, null) + ); + + var apiResponse = new DuckDuckGoApiResponse( + null, null, null, null, null, + null, null, null, null, null, + List.of( + new DuckDuckGoApiResponse.RelatedTopic( + null, null, null, nestedTopics, "Category") + ) + ); + + when(responseSpec.body(DuckDuckGoApiResponse.class)).thenReturn(apiResponse); + + var request = new SearchExecutionRequest(List.of("query"), 5); + SearchExecutionResponse response = webSearchService.executeSearch(request); + + assertThat(response.results()).hasSize(2); + assertThat(response.results().get(0).url()).isEqualTo("https://example.com/nested1"); + assertThat(response.results().get(1).url()).isEqualTo("https://example.com/nested2"); + } + } + + @Nested + @DisplayName("extractResults — definition and direct answer") + class DefinitionAndAnswer { + + @Test + @DisplayName("should extract definition result with source and URL") + void shouldExtractDefinition() { + var apiResponse = new DuckDuckGoApiResponse( + null, null, null, null, null, + null, null, + "A vaccine is a biological preparation that provides immunity.", + "Merriam-Webster", + "https://www.merriam-webster.com/dictionary/vaccine", + List.of() + ); + + when(responseSpec.body(DuckDuckGoApiResponse.class)).thenReturn(apiResponse); + + var request = new SearchExecutionRequest(List.of("define vaccine"), 3); + SearchExecutionResponse response = webSearchService.executeSearch(request); + + assertThat(response.results()).hasSize(1); + SearchResultItem item = response.results().get(0); + assertThat(item.title()).isEqualTo("Merriam-Webster"); + assertThat(item.url()).isEqualTo("https://www.merriam-webster.com/dictionary/vaccine"); + assertThat(item.snippet()).contains("biological preparation"); + } + + @Test + @DisplayName("should extract direct answer from API response") + void shouldExtractDirectAnswer() { + var apiResponse = new DuckDuckGoApiResponse( + null, null, null, null, null, + "42", "calc", + null, null, null, + List.of() + ); + + when(responseSpec.body(DuckDuckGoApiResponse.class)).thenReturn(apiResponse); + + var request = new SearchExecutionRequest(List.of("what is 6 times 7"), 3); + SearchExecutionResponse response = webSearchService.executeSearch(request); + + assertThat(response.results()).hasSize(1); + assertThat(response.results().get(0).title()).isEqualTo("Direct Answer"); + assertThat(response.results().get(0).snippet()).isEqualTo("42"); + } + } + + @Nested + @DisplayName("executeSearch — combined result types") + class CombinedResults { + + @Test + @DisplayName("should return results in priority order: abstract → answer → definition → topics") + void shouldReturnResultsInPriorityOrder() { + var apiResponse = new DuckDuckGoApiResponse( + "Abstract about topic", + "Detailed abstract text", + "Wikipedia", + "https://en.wikipedia.org/wiki/Topic", + "Topic Heading", + "Quick answer text", "factoid", + "Formal definition of the topic", + "Oxford Dictionary", + "https://oxford.com/topic", + List.of( + new DuckDuckGoApiResponse.RelatedTopic( + "Related info - additional context", + "https://example.com/related", + null, null, null + ) + ) + ); + + when(responseSpec.body(DuckDuckGoApiResponse.class)).thenReturn(apiResponse); + + var request = new SearchExecutionRequest(List.of("query"), 10); + SearchExecutionResponse response = webSearchService.executeSearch(request); + + assertThat(response.results()).hasSize(4); + assertThat(response.results().get(0).title()).isEqualTo("Topic Heading"); + assertThat(response.results().get(1).title()).isEqualTo("Direct Answer"); + assertThat(response.results().get(2).title()).isEqualTo("Oxford Dictionary"); + assertThat(response.results().get(3).url()).isEqualTo("https://example.com/related"); + } + + @Test + @DisplayName("should use default maxResultsPerQuery=3 when value is 0 or negative") + void shouldDefaultMaxResultsToThree() { + var apiResponse = new DuckDuckGoApiResponse( + "Abstract", "Abstract text", "Source", + "https://example.com/1", "Heading", + "Answer", null, + "Definition", "Dict", "https://example.com/2", + List.of( + new DuckDuckGoApiResponse.RelatedTopic( + "Topic 1", "https://example.com/3", null, null, null), + new DuckDuckGoApiResponse.RelatedTopic( + "Topic 2", "https://example.com/4", null, null, null) + ) + ); + + when(responseSpec.body(DuckDuckGoApiResponse.class)).thenReturn(apiResponse); + + var request = new SearchExecutionRequest(List.of("query"), 0); + SearchExecutionResponse response = webSearchService.executeSearch(request); + + assertThat(response.results()).hasSizeLessThanOrEqualTo(3); + } + } + + @Nested + @DisplayName("executeSearch — multiple queries scenario") + class MultipleQueries { + + @Test + @DisplayName("should aggregate results from multiple different queries") + void shouldAggregateMultipleQueryResults() { + var response1 = new DuckDuckGoApiResponse( + "Abstract1", "First topic abstract", "Wikipedia", + "https://en.wikipedia.org/wiki/First", "First Topic", + null, null, null, null, null, + List.of() + ); + + var response2 = new DuckDuckGoApiResponse( + "Abstract2", "Second topic abstract", "Wikipedia", + "https://en.wikipedia.org/wiki/Second", "Second Topic", + null, null, null, null, null, + List.of() + ); + + when(responseSpec.body(DuckDuckGoApiResponse.class)) + .thenReturn(response1) + .thenReturn(response2); + + var request = new SearchExecutionRequest( + List.of("first query", "second query"), + 3 + ); + + SearchExecutionResponse response = webSearchService.executeSearch(request); + + assertThat(response.results()).hasSize(2); + assertThat(response.results().get(0).title()).isEqualTo("First Topic"); + assertThat(response.results().get(1).title()).isEqualTo("Second Topic"); + } + } + + @Nested + @DisplayName("extractTitle — title parsing logic") + class TitleExtraction { + + @Test + @DisplayName("should extract title from text with ' - ' separator") + void shouldExtractTitleFromDashSeparator() { + var apiResponse = new DuckDuckGoApiResponse( + null, null, null, null, null, + null, null, null, null, null, + List.of( + new DuckDuckGoApiResponse.RelatedTopic( + "Climate Change - Climate change refers to long-term shifts in temperatures and weather patterns", + "https://example.com/climate", + null, null, null + ) + ) + ); + + when(responseSpec.body(DuckDuckGoApiResponse.class)).thenReturn(apiResponse); + + var request = new SearchExecutionRequest(List.of("climate change"), 3); + SearchExecutionResponse response = webSearchService.executeSearch(request); + + assertThat(response.results()).hasSize(1); + assertThat(response.results().get(0).title()).isEqualTo("Climate Change"); + } + + @Test + @DisplayName("should truncate very long text without separator to 60 chars") + void shouldTruncateLongTextWithoutSeparator() { + String longText = "A".repeat(100); + + var apiResponse = new DuckDuckGoApiResponse( + null, null, null, null, null, + null, null, null, null, null, + List.of( + new DuckDuckGoApiResponse.RelatedTopic( + longText, + "https://example.com/long", + null, null, null + ) + ) + ); + + when(responseSpec.body(DuckDuckGoApiResponse.class)).thenReturn(apiResponse); + + var request = new SearchExecutionRequest(List.of("query"), 3); + SearchExecutionResponse response = webSearchService.executeSearch(request); + + assertThat(response.results()).hasSize(1); + assertThat(response.results().get(0).title()).hasSize(63); + assertThat(response.results().get(0).title()).endsWith("..."); + } + } + + @Nested + @DisplayName("executeSearch — additional error scenarios") + class AdditionalErrors { + + @Test + @DisplayName("should throw ExternalSearchException on generic 500 Internal Server Error") + void shouldThrowOn500() { + when(responseSpec.body(DuckDuckGoApiResponse.class)) + .thenThrow(HttpServerErrorException.create( + HttpStatus.INTERNAL_SERVER_ERROR, "Internal Server Error", + org.springframework.http.HttpHeaders.EMPTY, + new byte[0], null)); + + var request = new SearchExecutionRequest(List.of("query"), 3); + + assertThatThrownBy(() -> webSearchService.executeSearch(request)) + .isInstanceOf(ExternalSearchException.class) + .hasMessageContaining("500"); + } + + @Test + @DisplayName("should wrap unexpected RuntimeException in ExternalSearchException") + void shouldWrapUnexpectedRuntimeException() { + when(responseSpec.body(DuckDuckGoApiResponse.class)) + .thenThrow(new RuntimeException("Connection reset")); + + var request = new SearchExecutionRequest(List.of("query"), 3); + + assertThatThrownBy(() -> webSearchService.executeSearch(request)) + .isInstanceOf(ExternalSearchException.class) + .hasMessageContaining("Unexpected error") + .hasCauseInstanceOf(RuntimeException.class); + } + } +} diff --git a/search-service/src/test/resources/application.yml b/search-service/src/test/resources/application.yml new file mode 100644 index 0000000..82c6b75 --- /dev/null +++ b/search-service/src/test/resources/application.yml @@ -0,0 +1,16 @@ + +spring: + application: + name: truthlens-search-service + config: + import: "optional:configserver:" + cloud: + config: + enabled: false + fail-fast: false + discovery: + enabled: false + +eureka: + client: + enabled: false diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..e9329c1 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,54 @@ +sonar.projectKey=truthlens +sonar.projectName=TruthLens +sonar.projectVersion=1.0-SNAPSHOT + +sonar.host.url=http://localhost:9000 +sonar.sourceEncoding=UTF-8 + +sonar.modules=backend,gateway-service,search-service,ocr-service,discovery-server,config-server + +backend.sonar.projectName=TruthLens Backend +backend.sonar.sources=src/main/java +backend.sonar.tests=src/test/java +backend.sonar.java.binaries=target/classes +backend.sonar.java.test.binaries=target/test-classes +backend.sonar.java.source=25 +backend.sonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml + +gateway-service.sonar.projectName=TruthLens Gateway +gateway-service.sonar.sources=src/main/java +gateway-service.sonar.tests=src/test/java +gateway-service.sonar.java.binaries=target/classes +gateway-service.sonar.java.test.binaries=target/test-classes +gateway-service.sonar.java.source=25 +gateway-service.sonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml + +search-service.sonar.projectName=TruthLens Search Service +search-service.sonar.sources=src/main/java +search-service.sonar.tests=src/test/java +search-service.sonar.java.binaries=target/classes +search-service.sonar.java.test.binaries=target/test-classes +search-service.sonar.java.source=25 +search-service.sonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml + +ocr-service.sonar.projectName=TruthLens OCR Service +ocr-service.sonar.sources=src/main/java +ocr-service.sonar.tests=src/test/java +ocr-service.sonar.java.binaries=target/classes +ocr-service.sonar.java.test.binaries=target/test-classes +ocr-service.sonar.java.source=25 +ocr-service.sonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml + +discovery-server.sonar.projectName=TruthLens Discovery Server +discovery-server.sonar.sources=src/main/java +discovery-server.sonar.tests=src/test/java +discovery-server.sonar.java.binaries=target/classes +discovery-server.sonar.java.test.binaries=target/test-classes +discovery-server.sonar.java.source=25 + +config-server.sonar.projectName=TruthLens Config Server +config-server.sonar.sources=src/main/java +config-server.sonar.tests=src/test/java +config-server.sonar.java.binaries=target/classes +config-server.sonar.java.test.binaries=target/test-classes +config-server.sonar.java.source=25