From 977d55acb8150b5ceecef861fcfbb001cde4fead Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Wed, 4 Mar 2026 09:54:53 +0100 Subject: [PATCH 1/7] feat: add feature flag for submitting quotes and update UI accordingly - Implemented a new API endpoint to fetch feature flags. - Updated the QuoteDisplay component to disable the submit link based on the feature flag. - Modified the Home component to fetch the feature flag and control the submission of new quotes. - Enhanced the SubmitQuote component to reflect the feature flag status and provide user feedback when submissions are disabled. - Introduced a utility for managing feature flags using Unleash. - Updated unleash configuration for quotes-loadgen. --- AGENTS.md | 39 ++ README.md | 65 ++++ docker-compose.yaml | 8 + quotes-analytics/.nais/unleash.yaml | 21 +- quotes-backend/.mise.toml | 3 + quotes-backend/.nais/app.yaml | 2 + quotes-backend/.nais/unleash.yaml | 16 +- quotes-backend/build.gradle.kts | 6 + .../io/nais/quotesbackend/Application.kt | 35 +- .../io/nais/quotesbackend/FeatureFlags.kt | 50 +++ quotes-frontend/.mise.toml | 3 + quotes-frontend/.nais/app.yaml | 2 + quotes-frontend/.nais/unleash.yaml | 12 +- quotes-frontend/package.json | 3 +- quotes-frontend/pnpm-lock.yaml | 340 ++++++++++++++++++ .../components/QuoteDisplay.test.tsx | 8 + quotes-frontend/src/app/api/features/route.ts | 9 + quotes-frontend/src/app/page.tsx | 10 +- quotes-frontend/src/app/submit-quote/page.tsx | 17 +- quotes-frontend/src/utils/unleash.ts | 31 ++ quotes-loadgen/.nais/unleash.yaml | 14 +- 21 files changed, 656 insertions(+), 38 deletions(-) create mode 100644 quotes-backend/src/main/kotlin/io/nais/quotesbackend/FeatureFlags.kt create mode 100644 quotes-frontend/src/app/api/features/route.ts create mode 100644 quotes-frontend/src/utils/unleash.ts diff --git a/AGENTS.md b/AGENTS.md index d1c191e..7e91ef2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,3 +146,42 @@ quotes-/ - Each service is self-contained with its own `.mise.toml`, `Dockerfile`, and `.nais/app.yaml` - Root `.mise.toml` orchestrates cross-service tasks (`mise run dev`, `mise run ci`) - `docker-compose.yaml` provides local infrastructure (PostgreSQL, Unleash, Grafana/OTEL) + +## Feature Flags (Unleash) + +Feature flags use [Unleash](https://docs.nais.io/services/feature-flagging/) via the NAIS platform. + +**Architecture:** + +- Each service has `.nais/unleash.yaml` that provisions an API token as a Kubernetes secret +- `envFrom` in `.nais/app.yaml` injects `UNLEASH_SERVER_API_URL`, `UNLEASH_SERVER_API_TOKEN`, `UNLEASH_SERVER_API_ENVIRONMENT` +- Backend uses `io.getunleash:unleash-client-java`, frontend uses `unleash-client` (Node.js) +- When Unleash is unavailable, flags default to **enabled** (graceful degradation) + +**Current flags:** + +- `quotes.submit` — gates the "Submit a New Quote" feature (backend + frontend) +- `quotes.errors` — enables simulated error injection in the backend (default: **disabled**) + +**Patterns:** + +```kotlin +// Kotlin: check flag, default true when Unleash is unavailable +FeatureFlags.isEnabled(FeatureFlags.QUOTES_SUBMIT) + +// Kotlin: error injection flag, default false (opt-in) +FeatureFlags.isEnabled(FeatureFlags.QUOTES_ERRORS, default = false) +``` + +```typescript +// TypeScript (server-side API routes): check flag +import { isEnabled, FEATURE_FLAGS } from '@/utils/unleash'; +isEnabled(FEATURE_FLAGS.QUOTES_SUBMIT); +``` + +**Rules:** + +- Register flag names as constants in `FeatureFlags.kt` / `unleash.ts` +- Always default to `true` so the app works without Unleash +- Use `GET /api/features` (backend or frontend) to inspect flag states +- Local Unleash admin UI: `http://localhost:4242` (started via `mise run infra:up`) diff --git a/README.md b/README.md index 0c42ce1..222ee66 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,71 @@ graph LR ``` +## Feature Flags with Unleash + +This project demonstrates [Unleash](https://docs.nais.io/services/feature-flagging/) feature flagging on the NAIS platform. Unleash lets you toggle features on and off without redeploying. + +### How it works + +Each service has an [Unleash API token](https://docs.nais.io/services/feature-flagging/#step-2-define-an-apitoken-for-your-application) defined in `.nais/unleash.yaml`. When deployed, the NAIS Unleash operator provisions a client token and stores it as a Kubernetes secret. The app reads the secret via `envFrom` in `.nais/app.yaml`: + +```yaml +# .nais/app.yaml +envFrom: + - secret: quotes-backend-unleash-api-token +``` + +This provides the environment variables `UNLEASH_SERVER_API_URL`, `UNLEASH_SERVER_API_TOKEN`, and `UNLEASH_SERVER_API_ENVIRONMENT` to the application at runtime. + +### Feature flags in use + +| Flag | Service | Effect | +| --------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `quotes.submit` | quotes-backend, quotes-frontend | Controls whether users can submit new quotes. When disabled, the backend returns 403 and the frontend hides/disables the submit button. | +| `quotes.errors` | quotes-backend | Enables simulated error injection (10% error rate on GET/POST endpoints). Default: **disabled**. Turn on to generate errors visible in dashboards and alerts. | + +### Adding a new feature flag + +1. **Create the toggle** in the [Unleash UI](http://localhost:4242) (or on NAIS at your team's Unleash instance) +2. **Check the flag in code:** + + **Kotlin (backend):** + + ```kotlin + if (FeatureFlags.isEnabled("my.new.flag")) { + // feature code + } + ``` + + **TypeScript (frontend, server-side):** + + ```typescript + import { isEnabled } from '@/utils/unleash'; + const enabled = isEnabled('my.new.flag'); + ``` + +3. **Register the flag name** in `FeatureFlags.kt` (backend) or `unleash.ts` (frontend) so it appears in the `/api/features` endpoint + +### Local development + +Unleash runs locally via docker-compose on port 4242. The admin UI is at (no login required with the dev setup). + +To create the `quotes.submit` toggle locally: + +1. Start infrastructure: `mise run infra:up` +2. Open +3. Create feature flags named `quotes.submit` and `quotes.errors` in the `development` environment +4. Enable or disable them to see the effect in the running application + +The local client token `default:development.client-token` is pre-configured in both `.mise.toml` (for `mise run dev`) and `docker-compose.yaml`. + +### Graceful degradation + +When Unleash is unavailable (no env vars set, or server unreachable), all feature flags default to **enabled**. This means: + +- Tests run without Unleash — all features work normally +- A misconfigured Unleash connection won't break the application + ## License The code in this repository is licensed under the MIT license. See [LICENSE](LICENSE) for more information. diff --git a/docker-compose.yaml b/docker-compose.yaml index 717937b..36d4910 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -48,6 +48,8 @@ services: DATABASE_SSL: false CHECK_VERSION: false SEND_TELEMETRY: false + UNLEASH_DEFAULT_ADMIN_USERNAME: admin + UNLEASH_DEFAULT_ADMIN_PASSWORD: unleash INIT_ADMIN_API_TOKENS: "*:*.admin-token" INIT_CLIENT_API_TOKENS: "default:development.client-token" ports: @@ -68,6 +70,9 @@ services: DB_URL: jdbc:postgresql://quotes-db:5432/quotes DB_USERNAME: quotes DB_PASSWORD: quotes + UNLEASH_SERVER_API_URL: http://unleash:4242/api + UNLEASH_SERVER_API_TOKEN: "default:development.client-token" + UNLEASH_SERVER_API_ENVIRONMENT: development ports: - "8080:8080" @@ -97,6 +102,9 @@ services: OTEL_SERVICE_NAME: quotes-frontend NEXT_PUBLIC_BACKEND_URL: http://quotes-backend:8080 ANALYTICS_SERVICE_URL: http://quotes-analytics:8081 + UNLEASH_SERVER_API_URL: http://unleash:4242/api + UNLEASH_SERVER_API_TOKEN: "default:development.client-token" + UNLEASH_SERVER_API_ENVIRONMENT: development ports: - "3000:3000" diff --git a/quotes-analytics/.nais/unleash.yaml b/quotes-analytics/.nais/unleash.yaml index a92aca1..7fb3282 100644 --- a/quotes-analytics/.nais/unleash.yaml +++ b/quotes-analytics/.nais/unleash.yaml @@ -1,11 +1,14 @@ -apiVersion: v1 -kind: ConfigMap +apiVersion: unleash.nais.io/v1 +kind: ApiToken metadata: - name: unleash-bootstrap-quotes-analytics - namespace: examples + name: quotes-analytics + namespace: { { namespace } } labels: - app: quotes-analytics - team: examples -data: - # No feature flags for analytics service yet - # Add toggles here when needed + team: { { namespace } } +spec: + unleashInstance: + apiVersion: unleash.nais.io/v1 + kind: RemoteUnleash + name: examples + secretName: quotes-analytics-unleash-api-token + environment: development diff --git a/quotes-backend/.mise.toml b/quotes-backend/.mise.toml index ebdfa00..d857b7e 100644 --- a/quotes-backend/.mise.toml +++ b/quotes-backend/.mise.toml @@ -8,6 +8,9 @@ JAVA_TOOL_OPTIONS = "-XX:+UseG1GC" DB_URL = "jdbc:postgresql://localhost:5433/quotes" DB_USERNAME = "quotes" DB_PASSWORD = "quotes" +UNLEASH_SERVER_API_URL = "http://localhost:4242/api" +UNLEASH_SERVER_API_TOKEN = "default:development.client-token" +UNLEASH_SERVER_API_ENVIRONMENT = "development" [tasks.dev] description = "Start development server with auto-reload" diff --git a/quotes-backend/.nais/app.yaml b/quotes-backend/.nais/app.yaml index f1f3d40..cad6c19 100755 --- a/quotes-backend/.nais/app.yaml +++ b/quotes-backend/.nais/app.yaml @@ -31,6 +31,8 @@ spec: value: "true" - name: OTEL_METRICS_EXEMPLAR_FILTER value: ALWAYS_ON + envFrom: + - secret: quotes-backend-unleash-api-token liveness: path: "/internal/health" port: 8080 diff --git a/quotes-backend/.nais/unleash.yaml b/quotes-backend/.nais/unleash.yaml index 6c62ea9..74acbe8 100644 --- a/quotes-backend/.nais/unleash.yaml +++ b/quotes-backend/.nais/unleash.yaml @@ -2,13 +2,13 @@ apiVersion: unleash.nais.io/v1 kind: ApiToken metadata: name: quotes-backend - namespace: {{ namespace }} + namespace: { { namespace } } labels: - team: {{ namespace }} + team: { { namespace } } spec: - unleashInstance: - apiVersion: unleash.nais.io/v1 - kind: RemoteUnleash - name: nais-demo - secretName: quotes-backend-unleash-api-token - environment: development + unleashInstance: + apiVersion: unleash.nais.io/v1 + kind: RemoteUnleash + name: examples + secretName: quotes-backend-unleash-api-token + environment: development diff --git a/quotes-backend/build.gradle.kts b/quotes-backend/build.gradle.kts index 0e6dc75..ffa4f94 100644 --- a/quotes-backend/build.gradle.kts +++ b/quotes-backend/build.gradle.kts @@ -38,6 +38,7 @@ val kotlinTestVersion = "2.2.21" val exposedVersion = "0.57.0" val postgresqlVersion = "42.7.5" val hikariVersion = "6.2.1" +val unleashVersion = "10.1.1" dependencies { // Ktor @@ -69,6 +70,9 @@ dependencies { implementation("org.postgresql:postgresql:$postgresqlVersion") implementation("com.zaxxer:HikariCP:$hikariVersion") + // Feature Flags + implementation("io.getunleash:unleash-client-java:$unleashVersion") + // Testing testImplementation("io.ktor:ktor-server-test-host:$ktorVersion") testImplementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") @@ -81,4 +85,6 @@ tasks.withType { testLogging { showStandardStreams = true } + environment("UNLEASH_SERVER_API_URL", "") + environment("UNLEASH_SERVER_API_TOKEN", "") } diff --git a/quotes-backend/src/main/kotlin/io/nais/quotesbackend/Application.kt b/quotes-backend/src/main/kotlin/io/nais/quotesbackend/Application.kt index db1f591..519c600 100644 --- a/quotes-backend/src/main/kotlin/io/nais/quotesbackend/Application.kt +++ b/quotes-backend/src/main/kotlin/io/nais/quotesbackend/Application.kt @@ -22,7 +22,17 @@ import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import org.slf4j.event.Level -var globalErrorRate: Double = 0.1 +var globalErrorRate: Double = 0.0 + +fun shouldInjectError(): Boolean { + val rate = if (FeatureFlags.isEnabled(FeatureFlags.QUOTES_ERRORS, default = false)) { + if (globalErrorRate > 0.0) globalErrorRate else 0.1 + } else { + globalErrorRate + } + if (rate <= 0.0) return false + return Random.nextDouble() < rate +} @Serializable data class Quote(val id: String, val text: String, val author: String) @@ -35,6 +45,9 @@ fun main() { fun Application.module() { log.info("Using global error rate: $globalErrorRate") + FeatureFlags.init() + log.info("Feature flags initialized: ${FeatureFlags.allFlags()}") + val database = DatabaseFactory.init() val quoteService = QuoteService(database) @@ -95,7 +108,7 @@ fun Application.module() { route("/api/quotes") { get { try { - if (Random.nextDouble() < globalErrorRate) { + if (shouldInjectError()) { throw IllegalStateException( "Database connection failed" ) @@ -129,7 +142,17 @@ fun Application.module() { } post { - if (Random.nextDouble() < globalErrorRate) { + if (!FeatureFlags.isEnabled(FeatureFlags.QUOTES_SUBMIT)) { + call.respond( + HttpStatusCode.Forbidden, + mapOf( + "error" to "FEATURE_DISABLED", + "message" to "Submitting quotes is currently disabled" + ) + ) + return@post + } + if (shouldInjectError()) { call.respond( HttpStatusCode.InternalServerError, "Simulated error for observability testing" @@ -285,7 +308,7 @@ fun Application.module() { get("/api/quotes/{id}") { try { - if (Random.nextDouble() < globalErrorRate) { + if (shouldInjectError()) { throw IllegalStateException( "Failed to retrieve quote due to database error" ) @@ -369,6 +392,10 @@ fun Application.module() { openAPI(path = "openapi") swaggerUI(path = "swagger") + get("/api/features") { + call.respond(FeatureFlags.allFlags()) + } + route("/internal") { get("/stats") { try { diff --git a/quotes-backend/src/main/kotlin/io/nais/quotesbackend/FeatureFlags.kt b/quotes-backend/src/main/kotlin/io/nais/quotesbackend/FeatureFlags.kt new file mode 100644 index 0000000..bceced7 --- /dev/null +++ b/quotes-backend/src/main/kotlin/io/nais/quotesbackend/FeatureFlags.kt @@ -0,0 +1,50 @@ +package io.nais.quotesbackend + +import io.getunleash.DefaultUnleash +import io.getunleash.Unleash +import io.getunleash.util.UnleashConfig +import org.slf4j.LoggerFactory + +object FeatureFlags { + const val QUOTES_SUBMIT = "quotes.submit" + const val QUOTES_ERRORS = "quotes.errors" + + private val log = LoggerFactory.getLogger(FeatureFlags::class.java) + private var unleash: Unleash? = null + + fun init(appName: String = "quotes-backend") { + if (unleash != null) return + val apiUrl = System.getenv("UNLEASH_SERVER_API_URL")?.takeIf { it.isNotBlank() } ?: return + val apiToken = System.getenv("UNLEASH_SERVER_API_TOKEN")?.takeIf { it.isNotBlank() } ?: return + val environment = System.getenv("UNLEASH_SERVER_API_ENVIRONMENT") ?: "development" + + try { + val config = UnleashConfig.builder() + .appName(appName) + .unleashAPI("$apiUrl/") + .apiKey(apiToken) + .environment(environment) + .build() + + unleash = DefaultUnleash(config) + log.info("Unleash initialized for environment: $environment") + } catch (e: Exception) { + log.warn("Failed to initialize Unleash, feature flags will default to enabled: ${e.message}") + } + } + + fun isEnabled(flag: String, default: Boolean = true): Boolean { + return unleash?.isEnabled(flag, default) ?: default + } + + fun allFlags(): Map { + return mapOf( + QUOTES_SUBMIT to isEnabled(QUOTES_SUBMIT), + QUOTES_ERRORS to isEnabled(QUOTES_ERRORS, default = false), + ) + } + + fun shutdown() { + unleash?.shutdown() + } +} diff --git a/quotes-frontend/.mise.toml b/quotes-frontend/.mise.toml index db3e723..7c7731f 100644 --- a/quotes-frontend/.mise.toml +++ b/quotes-frontend/.mise.toml @@ -5,6 +5,9 @@ node = "25.2.1" [env] NODE_ENV = "development" NEXT_TELEMETRY_DISABLED = "1" +UNLEASH_SERVER_API_URL = "http://localhost:4242/api" +UNLEASH_SERVER_API_TOKEN = "default:development.client-token" +UNLEASH_SERVER_API_ENVIRONMENT = "development" [tasks.dev] description = "Start development server with Turbopack" diff --git a/quotes-frontend/.nais/app.yaml b/quotes-frontend/.nais/app.yaml index ec2d46c..29374fe 100755 --- a/quotes-frontend/.nais/app.yaml +++ b/quotes-frontend/.nais/app.yaml @@ -41,6 +41,8 @@ spec: value: "true" - name: OTEL_METRICS_EXEMPLAR_FILTER value: ALWAYS_ON + envFrom: + - secret: quotes-frontend-unleash-api-token liveness: path: "/" port: 3000 diff --git a/quotes-frontend/.nais/unleash.yaml b/quotes-frontend/.nais/unleash.yaml index e7ae6b4..d108f50 100644 --- a/quotes-frontend/.nais/unleash.yaml +++ b/quotes-frontend/.nais/unleash.yaml @@ -6,9 +6,9 @@ metadata: labels: team: {{ namespace }} spec: - unleashInstance: - apiVersion: unleash.nais.io/v1 - kind: RemoteUnleash - name: nais-demo - secretName: quotes-frontend-unleash-api-token - environment: development + unleashInstance: + apiVersion: unleash.nais.io/v1 + kind: RemoteUnleash + name: examples + secretName: quotes-frontend-unleash-api-token + environment: development diff --git a/quotes-frontend/package.json b/quotes-frontend/package.json index 6b2cef4..d76729c 100644 --- a/quotes-frontend/package.json +++ b/quotes-frontend/package.json @@ -15,7 +15,8 @@ "next": "16.0.10", "pino": "^10.1.0", "react": "^19.2.3", - "react-dom": "^19.2.3" + "react-dom": "^19.2.3", + "unleash-client": "^6.3.1" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/quotes-frontend/pnpm-lock.yaml b/quotes-frontend/pnpm-lock.yaml index 839f989..933e1ad 100644 --- a/quotes-frontend/pnpm-lock.yaml +++ b/quotes-frontend/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: react-dom: specifier: ^19.2.3 version: 19.2.4(react@19.2.4) + unleash-client: + specifier: ^6.3.1 + version: 6.9.6 devDependencies: '@eslint/eslintrc': specifier: ^3 @@ -447,6 +450,10 @@ packages: '@noble/hashes': optional: true + '@gar/promise-retry@1.0.2': + resolution: {integrity: sha512-Lm/ZLhDZcBECta3TmCQSngiQykFdfw+QtI1/GYMsZd4l3nG+P8WLB16XuS7WaBGLQ+9E+cOcWQsth9cayuGt8g==} + engines: {node: ^20.17.0 || >=22.9.0} + '@heroicons/react@2.2.0': resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==} peerDependencies: @@ -694,6 +701,14 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@npmcli/agent@4.0.0': + resolution: {integrity: sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==} + engines: {node: ^20.17.0 || >=22.9.0} + + '@npmcli/fs@5.0.0': + resolution: {integrity: sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==} + engines: {node: ^20.17.0 || >=22.9.0} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -1295,6 +1310,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.9.17: resolution: {integrity: sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ==} hasBin: true @@ -1308,6 +1327,10 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1317,6 +1340,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + cacache@20.0.3: + resolution: {integrity: sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw==} + engines: {node: ^20.17.0 || >=22.9.0} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1719,6 +1746,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + fs-minipass@3.0.3: + resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1765,6 +1796,10 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -1824,6 +1859,9 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -1832,6 +1870,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1856,6 +1898,14 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + + ip-address@9.0.5: + resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==} + engines: {node: '>= 12'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -1987,6 +2037,9 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsbn@1.1.0: + resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} + jsdom@28.1.0: resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -2033,6 +2086,10 @@ packages: resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} engines: {node: '>=0.10'} + launchdarkly-eventsource@2.2.0: + resolution: {integrity: sha512-u38fYlLSq/m6oFz0MS1/76Sj2xzlYhTKZ+sf/vju6PA86PMc6fPlY5k8CdU79edLXjNwsvIQTDvDNy3llDqB8A==} + engines: {node: '>=0.12.0'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2132,6 +2189,10 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + make-fetch-happen@15.0.4: + resolution: {integrity: sha512-vM2sG+wbVeVGYcCm16mM3d5fuem9oC28n436HjsGO3LcxoTI8LNVa4rwZDn3f76+cWyT4GGJDxjTYU1I2nr6zw==} + engines: {node: ^20.17.0 || >=22.9.0} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2159,6 +2220,10 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -2169,9 +2234,45 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass-collect@2.0.1: + resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass-fetch@5.0.2: + resolution: {integrity: sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + minipass-flush@1.0.5: + resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==} + engines: {node: '>= 8'} + + minipass-pipeline@1.2.4: + resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} + engines: {node: '>=8'} + + minipass-sized@2.0.0: + resolution: {integrity: sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==} + engines: {node: '>=8'} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + murmurhash3js@3.0.1: + resolution: {integrity: sha512-KL8QYUaxq7kUbcl0Yto51rMcYt7E/4N4BG3/c96Iqw1PQrTRspu8Cpx4TZ4Nunib1d4bEkIH3gjCYlP2RLBdow==} + engines: {node: '>=0.10.0'} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2185,6 +2286,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + next@16.0.10: resolution: {integrity: sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA==} engines: {node: '>=20.9.0'} @@ -2271,6 +2376,10 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-map@7.0.4: + resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} + engines: {node: '>=18'} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2289,6 +2398,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -2337,6 +2450,10 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + proc-log@6.1.0: + resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} + engines: {node: ^20.17.0 || >=22.9.0} + process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} @@ -2415,6 +2532,10 @@ packages: engines: {node: '>= 0.4'} hasBin: true + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -2443,6 +2564,9 @@ packages: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -2505,6 +2629,18 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.7: + resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sonic-boom@4.2.0: resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} @@ -2516,6 +2652,13 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + + ssri@13.0.1: + resolution: {integrity: sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==} + engines: {node: ^20.17.0 || >=22.9.0} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -2692,6 +2835,18 @@ packages: resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} engines: {node: '>=20.18.1'} + unique-filename@5.0.0: + resolution: {integrity: sha512-2RaJTAvAb4owyjllTfXzFClJ7WsGxlykkPvCr9pA//LD9goVq+m4PPAeBgNodGZ7nSrntT/auWpJ6Y5IFXcfjg==} + engines: {node: ^20.17.0 || >=22.9.0} + + unique-slug@6.0.0: + resolution: {integrity: sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw==} + engines: {node: ^20.17.0 || >=22.9.0} + + unleash-client@6.9.6: + resolution: {integrity: sha512-Ecfb3GT61Z7CCARXeSG+aMAnO4SpwNwcCFSx39Z02IP+I3ZkDVnflNQZF8K6v9V2kQKN5bvJNLtLW/yGBxSs0Q==} + engines: {node: '>=20'} + unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} @@ -2837,6 +2992,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -3167,6 +3325,10 @@ snapshots: '@exodus/bytes@1.14.1': {} + '@gar/promise-retry@1.0.2': + dependencies: + retry: 0.13.1 + '@heroicons/react@2.2.0(react@19.2.4)': dependencies: react: 19.2.4 @@ -3349,6 +3511,20 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@npmcli/agent@4.0.0': + dependencies: + agent-base: 7.1.4 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 11.2.6 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + '@npmcli/fs@5.0.0': + dependencies: + semver: 7.7.3 + '@pinojs/redact@0.4.0': {} '@rolldown/pluginutils@1.0.0-rc.3': {} @@ -3923,6 +4099,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + baseline-browser-mapping@2.9.17: {} bidi-js@1.0.3: @@ -3938,6 +4116,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.4: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -3950,6 +4132,20 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + cacache@20.0.3: + dependencies: + '@npmcli/fs': 5.0.0 + fs-minipass: 3.0.3 + glob: 13.0.6 + lru-cache: 11.2.6 + minipass: 7.1.3 + minipass-collect: 2.0.1 + minipass-flush: 1.0.5 + minipass-pipeline: 1.2.4 + p-map: 7.0.4 + ssri: 13.0.1 + unique-filename: 5.0.0 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -4508,6 +4704,10 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + fs-minipass@3.0.3: + dependencies: + minipass: 7.1.3 + fsevents@2.3.3: optional: true @@ -4564,6 +4764,12 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@13.0.6: + dependencies: + minimatch: 10.2.4 + minipass: 7.1.3 + path-scurry: 2.0.2 + globals@14.0.0: {} globals@16.4.0: {} @@ -4613,6 +4819,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + http-cache-semantics@4.2.0: {} + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -4627,6 +4835,11 @@ snapshots: transitivePeerDependencies: - supports-color + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + optional: true + ignore@5.3.2: {} ignore@7.0.5: {} @@ -4646,6 +4859,13 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + ip-address@10.1.0: {} + + ip-address@9.0.5: + dependencies: + jsbn: 1.1.0 + sprintf-js: 1.1.3 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -4783,6 +5003,8 @@ snapshots: dependencies: argparse: 2.0.1 + jsbn@1.1.0: {} + jsdom@28.1.0: dependencies: '@acemir/cssom': 0.9.31 @@ -4841,6 +5063,8 @@ snapshots: dependencies: language-subtag-registry: 0.3.23 + launchdarkly-eventsource@2.2.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -4917,6 +5141,22 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + make-fetch-happen@15.0.4: + dependencies: + '@gar/promise-retry': 1.0.2 + '@npmcli/agent': 4.0.0 + cacache: 20.0.3 + http-cache-semantics: 4.2.0 + minipass: 7.1.3 + minipass-fetch: 5.0.2 + minipass-flush: 1.0.5 + minipass-pipeline: 1.2.4 + negotiator: 1.0.0 + proc-log: 6.1.0 + ssri: 13.0.1 + transitivePeerDependencies: + - supports-color + math-intrinsics@1.1.0: {} mdn-data@2.12.2: {} @@ -4936,6 +5176,10 @@ snapshots: min-indent@1.0.1: {} + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.4 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -4946,14 +5190,52 @@ snapshots: minimist@1.2.8: {} + minipass-collect@2.0.1: + dependencies: + minipass: 7.1.3 + + minipass-fetch@5.0.2: + dependencies: + minipass: 7.1.3 + minipass-sized: 2.0.0 + minizlib: 3.1.0 + optionalDependencies: + iconv-lite: 0.7.2 + + minipass-flush@1.0.5: + dependencies: + minipass: 3.3.6 + + minipass-pipeline@1.2.4: + dependencies: + minipass: 3.3.6 + + minipass-sized@2.0.0: + dependencies: + minipass: 7.1.3 + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + ms@2.1.3: {} + murmurhash3js@3.0.1: {} + nanoid@3.3.11: {} napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} + negotiator@1.0.0: {} + next@16.0.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.0.10 @@ -5059,6 +5341,8 @@ snapshots: dependencies: p-limit: 3.1.0 + p-map@7.0.4: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -5073,6 +5357,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.2.6 + minipass: 7.1.3 + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -5139,6 +5428,8 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + proc-log@6.1.0: {} + process-warning@5.0.0: {} prop-types@15.8.1: @@ -5221,6 +5512,8 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + retry@0.13.1: {} + reusify@1.1.0: {} rollup@4.56.0: @@ -5279,6 +5572,9 @@ snapshots: safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: + optional: true + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -5381,6 +5677,21 @@ snapshots: siginfo@2.0.0: {} + smart-buffer@4.2.0: {} + + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + socks: 2.8.7 + transitivePeerDependencies: + - supports-color + + socks@2.8.7: + dependencies: + ip-address: 10.1.0 + smart-buffer: 4.2.0 + sonic-boom@4.2.0: dependencies: atomic-sleep: 1.0.0 @@ -5389,6 +5700,12 @@ snapshots: split2@4.2.0: {} + sprintf-js@1.1.3: {} + + ssri@13.0.1: + dependencies: + minipass: 7.1.3 + stable-hash@0.0.5: {} stackback@0.0.2: {} @@ -5584,6 +5901,27 @@ snapshots: undici@7.22.0: {} + unique-filename@5.0.0: + dependencies: + unique-slug: 6.0.0 + + unique-slug@6.0.0: + dependencies: + imurmurhash: 0.1.4 + + unleash-client@6.9.6: + dependencies: + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + ip-address: 9.0.5 + launchdarkly-eventsource: 2.2.0 + make-fetch-happen: 15.0.4 + murmurhash3js: 3.0.1 + proxy-from-env: 1.1.0 + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + unrs-resolver@1.11.1: dependencies: napi-postinstall: 0.3.4 @@ -5746,6 +6084,8 @@ snapshots: yallist@3.1.1: {} + yallist@4.0.0: {} + yocto-queue@0.1.0: {} zod-validation-error@4.0.2(zod@3.25.76): diff --git a/quotes-frontend/src/__tests__/components/QuoteDisplay.test.tsx b/quotes-frontend/src/__tests__/components/QuoteDisplay.test.tsx index aa907ff..940fa55 100644 --- a/quotes-frontend/src/__tests__/components/QuoteDisplay.test.tsx +++ b/quotes-frontend/src/__tests__/components/QuoteDisplay.test.tsx @@ -83,6 +83,14 @@ describe("QuoteDisplay", () => { expect(submitLink).toHaveAttribute("href", "/submit-quote"); }); + it("disables submit link when disableNewQuote is true", () => { + render(); + + const submitLink = screen.getByText("Submit a New Quote"); + expect(submitLink.className).toContain("pointer-events-none"); + expect(submitLink.className).toContain("bg-gray-400"); + }); + it("applies error background when quote has no id", () => { const { container } = render( [flag, isEnabled(flag)]) + ); + return NextResponse.json(features); +} diff --git a/quotes-frontend/src/app/page.tsx b/quotes-frontend/src/app/page.tsx index 39ed722..c69b781 100644 --- a/quotes-frontend/src/app/page.tsx +++ b/quotes-frontend/src/app/page.tsx @@ -7,6 +7,7 @@ import logger from '@/utils/logger'; export default function Home() { const [quote, setQuote] = useState({ id: "", text: "Loading...", author: "" }); + const [submitEnabled, setSubmitEnabled] = useState(true); const fetchQuote = async () => { try { @@ -22,6 +23,13 @@ export default function Home() { }; useEffect(() => { + fetch('/api/features') + .then((res) => res.json()) + .then((features) => { + setSubmitEnabled(features['quotes.submit'] ?? true); + }) + .catch(() => setSubmitEnabled(true)); + const pathSegments = window.location.pathname.split('/'); const quoteId = pathSegments[pathSegments.length - 1]; @@ -53,7 +61,7 @@ export default function Home() { handleThumbsDown={handleThumbsDown} fetchQuote={fetchQuote} disableRandomQuote={false} - disableNewQuote={false} + disableNewQuote={!submitEnabled} /> ); } diff --git a/quotes-frontend/src/app/submit-quote/page.tsx b/quotes-frontend/src/app/submit-quote/page.tsx index 82a0e62..8b3efac 100644 --- a/quotes-frontend/src/app/submit-quote/page.tsx +++ b/quotes-frontend/src/app/submit-quote/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import Link from "next/link"; import logger from "@/utils/logger"; @@ -8,6 +8,18 @@ export default function SubmitQuote() { const [text, setText] = useState(""); const [author, setAuthor] = useState(""); const [message, setMessage] = useState(""); + const [submitEnabled, setSubmitEnabled] = useState(true); + + useEffect(() => { + fetch('/api/features') + .then((res) => res.json()) + .then((features) => { + const enabled = features['quotes.submit'] ?? true; + setSubmitEnabled(enabled); + if (!enabled) setMessage("Submitting new quotes is currently disabled."); + }) + .catch(() => setSubmitEnabled(true)); + }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -104,7 +116,8 @@ export default function SubmitQuote() { diff --git a/quotes-frontend/src/utils/unleash.ts b/quotes-frontend/src/utils/unleash.ts new file mode 100644 index 0000000..e1a9b2a --- /dev/null +++ b/quotes-frontend/src/utils/unleash.ts @@ -0,0 +1,31 @@ +import { initialize, Unleash } from 'unleash-client'; + +let unleash: Unleash | null = null; + +export function getUnleash(): Unleash | null { + if (unleash) return unleash; + + const url = process.env.UNLEASH_SERVER_API_URL; + const token = process.env.UNLEASH_SERVER_API_TOKEN; + if (!url || !token) return null; + + unleash = initialize({ + url: `${url}/`, + appName: 'quotes-frontend', + customHeaders: { Authorization: token }, + environment: process.env.UNLEASH_SERVER_API_ENVIRONMENT || 'development', + }); + + return unleash; +} + +export function isEnabled(flag: string, defaultValue = true): boolean { + const client = getUnleash(); + if (!client) return defaultValue; + return client.isEnabled(flag, undefined, defaultValue); +} + +export const FEATURE_FLAGS = { + QUOTES_SUBMIT: 'quotes.submit', + QUOTES_ERRORS: 'quotes.errors', +} as const; diff --git a/quotes-loadgen/.nais/unleash.yaml b/quotes-loadgen/.nais/unleash.yaml index e7ae6b4..15e036b 100644 --- a/quotes-loadgen/.nais/unleash.yaml +++ b/quotes-loadgen/.nais/unleash.yaml @@ -1,14 +1,14 @@ apiVersion: unleash.nais.io/v1 kind: ApiToken metadata: - name: quotes-frontend + name: quotes-loadgen namespace: {{ namespace }} labels: team: {{ namespace }} spec: - unleashInstance: - apiVersion: unleash.nais.io/v1 - kind: RemoteUnleash - name: nais-demo - secretName: quotes-frontend-unleash-api-token - environment: development + unleashInstance: + apiVersion: unleash.nais.io/v1 + kind: RemoteUnleash + name: examples + secretName: quotes-loadgen-unleash-api-token + environment: development From ecda714e368a793327ce5cc2ec6605feef782d60 Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Wed, 4 Mar 2026 09:57:59 +0100 Subject: [PATCH 2/7] fix: update action versions in workflow files for consistency and stability --- .github/workflows/codeql.yml | 8 ++++---- .github/workflows/dependabot-auto-merge.yaml | 2 +- .github/workflows/service-pipeline.yaml | 10 +++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c32c0e9..a752417 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -61,11 +61,11 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # ratchet:actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # ratchet:actions/checkout@v5 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@1b168cd39490f61582a9beae412bb7057a6b2c4e # ratchet:github/codeql-action/init@v3 + uses: github/codeql-action/init@ae9ef3a1d2e3413523c3741725c30064970cc0d4 # ratchet:github/codeql-action/init@v3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -84,7 +84,7 @@ jobs: # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality - uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # ratchet:actions/setup-java@v5 + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # ratchet:actions/setup-java@v5 with: java-version: 21 distribution: 'temurin' @@ -98,7 +98,7 @@ jobs: ./gradlew clean build - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@1b168cd39490f61582a9beae412bb7057a6b2c4e # ratchet:github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@ae9ef3a1d2e3413523c3741725c30064970cc0d4 # ratchet:github/codeql-action/analyze@v3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/dependabot-auto-merge.yaml b/.github/workflows/dependabot-auto-merge.yaml index f12d894..881bd73 100644 --- a/.github/workflows/dependabot-auto-merge.yaml +++ b/.github/workflows/dependabot-auto-merge.yaml @@ -6,7 +6,7 @@ permissions: jobs: auto-merge: - uses: nais/actions/.github/workflows/dependabot-auto-merge.yaml@9422b590097ffabcf6ef2e34f41e99acd2634b9d # ratchet:nais/actions/.github/workflows/dependabot-auto-merge.yaml@main + uses: nais/actions/.github/workflows/dependabot-auto-merge.yaml@f48f094a6474ff06e70df118f21f13c1ac349d37 # ratchet:nais/actions/.github/workflows/dependabot-auto-merge.yaml@main secrets: DEPENDABOT_AUTO_MERGE_APP_ID: ${{ secrets.DEPENDABOT_AUTO_MERGE_APP_ID }} DEPENDABOT_AUTO_MERGE_APP_SECRET: ${{ secrets.DEPENDABOT_AUTO_MERGE_APP_SECRET }} diff --git a/.github/workflows/service-pipeline.yaml b/.github/workflows/service-pipeline.yaml index 83be121..38d6fe3 100644 --- a/.github/workflows/service-pipeline.yaml +++ b/.github/workflows/service-pipeline.yaml @@ -50,9 +50,9 @@ jobs: MISE_NODE_VERIFY: false steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # ratchet:actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # ratchet:actions/checkout@v5 - - uses: jdx/mise-action@146a28175021df8ca24f8ee1828cc2a60f980bd5 # ratchet:jdx/mise-action@v3 + - uses: jdx/mise-action@e79ddf65a11cec7b0e882bedced08d6e976efb2d # ratchet:jdx/mise-action@v3 with: version: 2024.12.18 install: true @@ -84,8 +84,8 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # ratchet:actions/checkout@v5 - - uses: nais/platform-build-push-sign@8be8359cd90915318ee8ab5fbc8337d04937ae70 # ratchet:nais/platform-build-push-sign@main + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # ratchet:actions/checkout@v5 + - uses: nais/platform-build-push-sign@f276e60f3898b076a67bfc94b52ffbdb885224a7 # ratchet:nais/platform-build-push-sign@main id: image with: context: ${{ inputs.service-path }} @@ -105,7 +105,7 @@ jobs: runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # ratchet:actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # ratchet:actions/checkout@v5 - uses: nais/deploy/actions/deploy@fa754451577294aae42872a69b888b3470478ec1 # ratchet:nais/deploy/actions/deploy@v2 env: CLUSTER: dev-gcp From 766bdc76673b2570d78c583c546ddc60f182a2b2 Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Wed, 4 Mar 2026 09:59:09 +0100 Subject: [PATCH 3/7] feat: sanitize quote IDs for logging to enhance security and prevent logging of control characters --- .../Controllers/AnalyticsController.cs | 15 ++++++++++----- .../Services/QuotesAnalyticsService.cs | 12 ++++++++---- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/quotes-analytics/Controllers/AnalyticsController.cs b/quotes-analytics/Controllers/AnalyticsController.cs index 1cc72be..221c68f 100644 --- a/quotes-analytics/Controllers/AnalyticsController.cs +++ b/quotes-analytics/Controllers/AnalyticsController.cs @@ -58,30 +58,32 @@ public async Task> GetAnalyticsForQuote(string id) return BadRequest(new { error = "Invalid quote ID", message = "Quote ID cannot be empty" }); } + var safeId = SanitizeForLog(id); + try { var analytics = await _analyticsService.GetAnalyticsForQuoteAsync(id); - _logger.LogInformation("Retrieved analytics for quote {QuoteId}", id); + _logger.LogInformation("Retrieved analytics for quote {QuoteId}", safeId); return Ok(analytics); } catch (InvalidOperationException ex) when (ex.Message.Contains("request URI")) { - _logger.LogError(ex, "HttpClient configuration error for quote {QuoteId}", id); + _logger.LogError(ex, "HttpClient configuration error for quote {QuoteId}", safeId); return StatusCode(503, new { error = "Backend service unavailable", message = "Service configuration error" }); } catch (InvalidOperationException ex) { - _logger.LogWarning(ex, "Quote {QuoteId} not found", id); + _logger.LogWarning(ex, "Quote {QuoteId} not found", safeId); return NotFound(new { error = "Quote not found", message = ex.Message, quoteId = id }); } catch (HttpRequestException ex) { - _logger.LogError(ex, "Failed to connect to quotes backend service for quote {QuoteId}", id); + _logger.LogError(ex, "Failed to connect to quotes backend service for quote {QuoteId}", safeId); return StatusCode(503, new { error = "Backend service unavailable", message = "Unable to connect to quotes service" }); } catch (Exception ex) { - _logger.LogError(ex, "Failed to get analytics for quote {QuoteId}", id); + _logger.LogError(ex, "Failed to get analytics for quote {QuoteId}", safeId); return StatusCode(500, new { error = "Failed to fetch analytics", message = ex.Message }); } } @@ -113,4 +115,7 @@ public async Task> GetSummary() return StatusCode(500, new { error = "Failed to fetch summary", message = ex.Message }); } } + + private static string SanitizeForLog(string input) => + string.Concat(input.Where(c => !char.IsControl(c))); } diff --git a/quotes-analytics/Services/QuotesAnalyticsService.cs b/quotes-analytics/Services/QuotesAnalyticsService.cs index 713a5af..92cb62d 100644 --- a/quotes-analytics/Services/QuotesAnalyticsService.cs +++ b/quotes-analytics/Services/QuotesAnalyticsService.cs @@ -105,20 +105,21 @@ public async Task> GetAllAnalyticsAsync() public async Task GetAnalyticsForQuoteAsync(string quoteId) { using var activity = ActivitySource.StartActivity("GetAnalyticsForQuote", ActivityKind.Internal); - activity?.SetTag("quote.id", quoteId); + var safeQuoteId = SanitizeForLog(quoteId); + activity?.SetTag("quote.id", safeQuoteId); try { // Check cache first if (_analyticsCache.TryGetValue(quoteId, out var cachedAnalytics)) { - _logger.LogInformation("Returning cached analytics for quote {QuoteId}", quoteId); + _logger.LogInformation("Returning cached analytics for quote {QuoteId}", safeQuoteId); activity?.SetTag("cache.hit", true); return cachedAnalytics; } activity?.SetTag("cache.hit", false); - _logger.LogInformation("Fetching quote {QuoteId} from backend", quoteId); + _logger.LogInformation("Fetching quote {QuoteId} from backend", safeQuoteId); var response = await _httpClient.GetAsync($"/api/quotes/{quoteId}"); response.EnsureSuccessStatusCode(); @@ -134,7 +135,7 @@ public async Task GetAnalyticsForQuoteAsync(string quoteId) } catch (HttpRequestException ex) { - _logger.LogError(ex, "Failed to fetch quote {QuoteId} from backend", quoteId); + _logger.LogError(ex, "Failed to fetch quote {QuoteId} from backend", safeQuoteId); activity?.SetStatus(ActivityStatusCode.Error, ex.Message); activity?.AddEvent(new ActivityEvent("exception", tags: new ActivityTagsCollection @@ -352,4 +353,7 @@ private static string CategorizeQuote(string text, string author) return "General"; } + + private static string SanitizeForLog(string input) => + string.Concat(input.Where(c => !char.IsControl(c))); } From db4163f6f8e1c4bfebad1ca48596ec4a84be073f Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Wed, 4 Mar 2026 09:59:46 +0100 Subject: [PATCH 4/7] fix: change Dependabot trigger to pull_request_target for improved security --- .github/workflows/dependabot-auto-merge.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dependabot-auto-merge.yaml b/.github/workflows/dependabot-auto-merge.yaml index 881bd73..47b7a5f 100644 --- a/.github/workflows/dependabot-auto-merge.yaml +++ b/.github/workflows/dependabot-auto-merge.yaml @@ -1,5 +1,5 @@ name: Dependabot -on: pull_request +on: pull_request_target permissions: pull-requests: write From 946abaebcd70695904710b417c8e5a7078e5039a Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Wed, 4 Mar 2026 10:06:32 +0100 Subject: [PATCH 5/7] refactor: change quote ID type from string to int for improved validation and consistency --- .../Controllers/AnalyticsController.cs | 22 ++++++++----------- .../Services/QuotesAnalyticsService.cs | 20 ++++++++--------- .../Tests/QuotesAnalyticsServiceTests.cs | 20 ++++++++--------- .../Tests/RoutesIntegrationTests.cs | 19 ++++------------ 4 files changed, 32 insertions(+), 49 deletions(-) diff --git a/quotes-analytics/Controllers/AnalyticsController.cs b/quotes-analytics/Controllers/AnalyticsController.cs index 221c68f..afcf489 100644 --- a/quotes-analytics/Controllers/AnalyticsController.cs +++ b/quotes-analytics/Controllers/AnalyticsController.cs @@ -53,37 +53,35 @@ public async Task>> GetAllAnalytics() [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task> GetAnalyticsForQuote(string id) { - if (string.IsNullOrWhiteSpace(id)) + if (!int.TryParse(id, out var quoteId)) { - return BadRequest(new { error = "Invalid quote ID", message = "Quote ID cannot be empty" }); + return BadRequest(new { error = "Invalid quote ID", message = "Quote ID must be a number" }); } - var safeId = SanitizeForLog(id); - try { - var analytics = await _analyticsService.GetAnalyticsForQuoteAsync(id); - _logger.LogInformation("Retrieved analytics for quote {QuoteId}", safeId); + var analytics = await _analyticsService.GetAnalyticsForQuoteAsync(quoteId); + _logger.LogInformation("Retrieved analytics for quote {QuoteId}", quoteId); return Ok(analytics); } catch (InvalidOperationException ex) when (ex.Message.Contains("request URI")) { - _logger.LogError(ex, "HttpClient configuration error for quote {QuoteId}", safeId); + _logger.LogError(ex, "HttpClient configuration error for quote {QuoteId}", quoteId); return StatusCode(503, new { error = "Backend service unavailable", message = "Service configuration error" }); } catch (InvalidOperationException ex) { - _logger.LogWarning(ex, "Quote {QuoteId} not found", safeId); - return NotFound(new { error = "Quote not found", message = ex.Message, quoteId = id }); + _logger.LogWarning(ex, "Quote {QuoteId} not found", quoteId); + return NotFound(new { error = "Quote not found", quoteId }); } catch (HttpRequestException ex) { - _logger.LogError(ex, "Failed to connect to quotes backend service for quote {QuoteId}", safeId); + _logger.LogError(ex, "Failed to connect to quotes backend service for quote {QuoteId}", quoteId); return StatusCode(503, new { error = "Backend service unavailable", message = "Unable to connect to quotes service" }); } catch (Exception ex) { - _logger.LogError(ex, "Failed to get analytics for quote {QuoteId}", safeId); + _logger.LogError(ex, "Failed to get analytics for quote {QuoteId}", quoteId); return StatusCode(500, new { error = "Failed to fetch analytics", message = ex.Message }); } } @@ -116,6 +114,4 @@ public async Task> GetSummary() } } - private static string SanitizeForLog(string input) => - string.Concat(input.Where(c => !char.IsControl(c))); } diff --git a/quotes-analytics/Services/QuotesAnalyticsService.cs b/quotes-analytics/Services/QuotesAnalyticsService.cs index 92cb62d..c2581a7 100644 --- a/quotes-analytics/Services/QuotesAnalyticsService.cs +++ b/quotes-analytics/Services/QuotesAnalyticsService.cs @@ -102,40 +102,40 @@ public async Task> GetAllAnalyticsAsync() } } - public async Task GetAnalyticsForQuoteAsync(string quoteId) + public async Task GetAnalyticsForQuoteAsync(int quoteId) { using var activity = ActivitySource.StartActivity("GetAnalyticsForQuote", ActivityKind.Internal); - var safeQuoteId = SanitizeForLog(quoteId); - activity?.SetTag("quote.id", safeQuoteId); + activity?.SetTag("quote.id", quoteId); + var quoteIdStr = quoteId.ToString(); try { // Check cache first - if (_analyticsCache.TryGetValue(quoteId, out var cachedAnalytics)) + if (_analyticsCache.TryGetValue(quoteIdStr, out var cachedAnalytics)) { - _logger.LogInformation("Returning cached analytics for quote {QuoteId}", safeQuoteId); + _logger.LogInformation("Returning cached analytics for quote {QuoteId}", quoteId); activity?.SetTag("cache.hit", true); return cachedAnalytics; } activity?.SetTag("cache.hit", false); - _logger.LogInformation("Fetching quote {QuoteId} from backend", safeQuoteId); + _logger.LogInformation("Fetching quote {QuoteId} from backend", quoteId); - var response = await _httpClient.GetAsync($"/api/quotes/{quoteId}"); + var response = await _httpClient.GetAsync($"/api/quotes/{quoteIdStr}"); response.EnsureSuccessStatusCode(); var quote = await response.Content.ReadFromJsonAsync(); if (quote == null) { - throw new InvalidOperationException($"Quote {quoteId} not found"); + throw new InvalidOperationException($"Quote {quoteIdStr} not found"); } return await AnalyzeQuoteAsync(quote); } catch (HttpRequestException ex) { - _logger.LogError(ex, "Failed to fetch quote {QuoteId} from backend", safeQuoteId); + _logger.LogError(ex, "Failed to fetch quote {QuoteId} from backend", quoteId); activity?.SetStatus(ActivityStatusCode.Error, ex.Message); activity?.AddEvent(new ActivityEvent("exception", tags: new ActivityTagsCollection @@ -354,6 +354,4 @@ private static string CategorizeQuote(string text, string author) return "General"; } - private static string SanitizeForLog(string input) => - string.Concat(input.Where(c => !char.IsControl(c))); } diff --git a/quotes-analytics/Tests/QuotesAnalyticsServiceTests.cs b/quotes-analytics/Tests/QuotesAnalyticsServiceTests.cs index 5efc43e..eb4d3f3 100644 --- a/quotes-analytics/Tests/QuotesAnalyticsServiceTests.cs +++ b/quotes-analytics/Tests/QuotesAnalyticsServiceTests.cs @@ -52,7 +52,7 @@ public async Task GetAnalyticsForQuoteAsync_ReturnsAnalytics() var quote = new { Id = "42", Text = "Deploy with confidence", Author = "Nais Team" }; var service = CreateService(JsonSerializer.Serialize(quote)); - var result = await service.GetAnalyticsForQuoteAsync("42"); + var result = await service.GetAnalyticsForQuoteAsync(42); result.QuoteId.Should().Be("42"); result.Text.Should().Be("Deploy with confidence"); @@ -67,8 +67,8 @@ public async Task GetAnalyticsForQuoteAsync_UsesCacheOnSecondCall() var quote = new { Id = "1", Text = "Test quote", Author = "Author" }; var service = CreateService(JsonSerializer.Serialize(quote)); - var first = await service.GetAnalyticsForQuoteAsync("1"); - var second = await service.GetAnalyticsForQuoteAsync("1"); + var first = await service.GetAnalyticsForQuoteAsync(1); + var second = await service.GetAnalyticsForQuoteAsync(1); second.QuoteId.Should().Be(first.QuoteId); second.Text.Should().Be(first.Text); @@ -80,7 +80,7 @@ public async Task AnalyzeQuote_CountsWordsCorrectly() var quote = new { Id = "1", Text = "one two three four five", Author = "A" }; var service = CreateService(JsonSerializer.Serialize(quote)); - var result = await service.GetAnalyticsForQuoteAsync("1"); + var result = await service.GetAnalyticsForQuoteAsync(1); result.WordCount.Should().Be(5); } @@ -92,7 +92,7 @@ public async Task AnalyzeQuote_CountsCharactersCorrectly() var quote = new { Id = "1", Text = text, Author = "A" }; var service = CreateService(JsonSerializer.Serialize(quote)); - var result = await service.GetAnalyticsForQuoteAsync("1"); + var result = await service.GetAnalyticsForQuoteAsync(1); result.CharacterCount.Should().Be(text.Length); } @@ -103,7 +103,7 @@ public async Task AnalyzeQuote_SentimentScoreInRange() var quote = new { Id = "1", Text = "This is a great and amazing quote", Author = "A" }; var service = CreateService(JsonSerializer.Serialize(quote)); - var result = await service.GetAnalyticsForQuoteAsync("1"); + var result = await service.GetAnalyticsForQuoteAsync(1); result.SentimentScore.Should().BeInRange(-1.0, 1.0); } @@ -114,7 +114,7 @@ public async Task AnalyzeQuote_CategorizesDeployAsPlatform() var quote = new { Id = "1", Text = "Deploy your app to production", Author = "A" }; var service = CreateService(JsonSerializer.Serialize(quote)); - var result = await service.GetAnalyticsForQuoteAsync("1"); + var result = await service.GetAnalyticsForQuoteAsync(1); result.Category.Should().Be("Platform"); } @@ -125,7 +125,7 @@ public async Task AnalyzeQuote_CategorizesSecurityQuote() var quote = new { Id = "1", Text = "Secure by default is the way", Author = "A" }; var service = CreateService(JsonSerializer.Serialize(quote)); - var result = await service.GetAnalyticsForQuoteAsync("1"); + var result = await service.GetAnalyticsForQuoteAsync(1); result.Category.Should().Be("Security"); } @@ -136,7 +136,7 @@ public async Task AnalyzeQuote_CategorizesDevOpsQuote() var quote = new { Id = "1", Text = "Continuous delivery is the goal", Author = "DevOps" }; var service = CreateService(JsonSerializer.Serialize(quote)); - var result = await service.GetAnalyticsForQuoteAsync("1"); + var result = await service.GetAnalyticsForQuoteAsync(1); result.Category.Should().Be("DevOps"); } @@ -147,7 +147,7 @@ public async Task AnalyzeQuote_DefaultsToGeneral() var quote = new { Id = "1", Text = "Just a regular quote", Author = "Regular Person" }; var service = CreateService(JsonSerializer.Serialize(quote)); - var result = await service.GetAnalyticsForQuoteAsync("1"); + var result = await service.GetAnalyticsForQuoteAsync(1); result.Category.Should().Be("General"); } diff --git a/quotes-analytics/Tests/RoutesIntegrationTests.cs b/quotes-analytics/Tests/RoutesIntegrationTests.cs index d041787..1d9a372 100644 --- a/quotes-analytics/Tests/RoutesIntegrationTests.cs +++ b/quotes-analytics/Tests/RoutesIntegrationTests.cs @@ -68,22 +68,11 @@ public async Task HealthEndpoints_ReturnCorrectFormat() [Fact] public async Task AnalyticsRoutes_ExistInRouting() { - // Test that routes are properly registered (should not return 404) - // This tests routing configuration independent of backend availability - var routes = new[] - { - "/api/analytics/test-quote-id" - }; + var response = await _client.GetAsync("/api/analytics/1"); - foreach (var route in routes) - { - var response = await _client.GetAsync(route); - - // Route should exist (not 404), but may return error due to backend unavailability - // 404 means the route isn't configured, other errors mean the route exists but fails - response.StatusCode.Should().NotBe(HttpStatusCode.NotFound, - $"Route {route} should be configured in the controller"); - } + // Route should exist (not 404), but may return error due to backend unavailability + response.StatusCode.Should().NotBe(HttpStatusCode.NotFound, + "Route /api/analytics/{id} should be configured in the controller"); } [Fact] From 522df9ade8b9e9e56b41873128ac6133b9ba59a1 Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Wed, 4 Mar 2026 10:27:40 +0100 Subject: [PATCH 6/7] feat: add security scan tasks for all services and update feature flag handling --- .github/agents/nais-agent.agent.md | 2 +- .github/workflows/dependabot-auto-merge.yaml | 1 + .mise.toml | 29 +++++++++++++++++++ AGENTS.md | 4 +-- README.md | 8 ++--- quotes-analytics/.mise.toml | 5 ++++ quotes-analytics/.nais/unleash.yaml | 4 +-- quotes-analytics/Dockerfile | 1 + quotes-backend/.mise.toml | 6 +++- quotes-backend/.nais/unleash.yaml | 4 +-- quotes-backend/Dockerfile | 2 +- .../io/nais/quotesbackend/FeatureFlags.kt | 2 +- quotes-frontend/.mise.toml | 5 ++++ quotes-frontend/src/app/api/features/route.ts | 5 +++- quotes-frontend/src/utils/unleash.ts | 22 ++++++++++---- quotes-loadgen/.mise.toml | 5 ++++ quotes-loadgen/internal/metrics/metrics.go | 2 +- 17 files changed, 85 insertions(+), 22 deletions(-) diff --git a/.github/agents/nais-agent.agent.md b/.github/agents/nais-agent.agent.md index b3fee2c..62e98d6 100644 --- a/.github/agents/nais-agent.agent.md +++ b/.github/agents/nais-agent.agent.md @@ -33,7 +33,7 @@ metadata: labels: team: team-namespace spec: - image: { { image } } # Replaced by CI/CD + image: {{ image }} # Replaced by CI/CD port: 8080 # Observability (required) diff --git a/.github/workflows/dependabot-auto-merge.yaml b/.github/workflows/dependabot-auto-merge.yaml index 47b7a5f..4d2daf6 100644 --- a/.github/workflows/dependabot-auto-merge.yaml +++ b/.github/workflows/dependabot-auto-merge.yaml @@ -6,6 +6,7 @@ permissions: jobs: auto-merge: + if: github.actor == 'dependabot[bot]' uses: nais/actions/.github/workflows/dependabot-auto-merge.yaml@f48f094a6474ff06e70df118f21f13c1ac349d37 # ratchet:nais/actions/.github/workflows/dependabot-auto-merge.yaml@main secrets: DEPENDABOT_AUTO_MERGE_APP_ID: ${{ secrets.DEPENDABOT_AUTO_MERGE_APP_ID }} diff --git a/.mise.toml b/.mise.toml index a2dc987..f00de5d 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,6 +1,7 @@ [tools] # Root tools for the entire project (kept minimal, each service manages its own) watchexec = "2.3.2" +semgrep = "latest" # Note: ratchet can be installed separately with: go install github.com/sethvargo/ratchet@latest [env] @@ -155,6 +156,10 @@ run = "cd quotes-backend && mise run lint" description = "Clean backend service" run = "cd quotes-backend && mise run clean" +[tasks."backend:security-scan"] +description = "Security scan backend service" +run = "cd quotes-backend && mise run security-scan" + # Frontend (Next.js) [tasks."frontend:dev"] description = "Start frontend in development mode" @@ -177,6 +182,10 @@ run = "cd quotes-frontend && mise run lint" description = "Clean frontend service" run = "cd quotes-frontend && mise run clean" +[tasks."frontend:security-scan"] +description = "Security scan frontend service" +run = "cd quotes-frontend && mise run security-scan" + # Analytics (.NET) [tasks."analytics:dev"] description = "Start analytics in development mode" @@ -198,6 +207,10 @@ run = "cd quotes-analytics && mise run lint" description = "Clean analytics service" run = "cd quotes-analytics && mise run clean" +[tasks."analytics:security-scan"] +description = "Security scan analytics service" +run = "cd quotes-analytics && mise run security-scan" + # Load Generator (Go) [tasks."loadgen:dev"] description = "Start load generator" @@ -227,6 +240,10 @@ run = "cd quotes-loadgen && mise run lint" description = "Clean load generator" run = "cd quotes-loadgen && mise run clean" +[tasks."loadgen:security-scan"] +description = "Security scan load generator" +run = "cd quotes-loadgen && mise run security-scan" + # ============================================================================= # ORCHESTRATION TASKS # ============================================================================= @@ -275,6 +292,17 @@ run = [ "echo '✅ All services cleaned'", ] +[tasks."security-scan"] +description = "Run security scanning for all services" +run = [ + "echo '🔒 Running security scans for all services...'", + "mise run backend:security-scan", + "mise run frontend:security-scan", + "mise run analytics:security-scan", + "mise run loadgen:security-scan", + "echo '✅ All security scans completed'", +] + [tasks.ci] description = "Run full CI pipeline" run = [ @@ -282,6 +310,7 @@ run = [ "mise run lint", "mise run test", "mise run build", + "mise run security-scan", "echo '✅ CI pipeline completed successfully'", ] diff --git a/AGENTS.md b/AGENTS.md index 7e91ef2..01ab1dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,7 +156,7 @@ Feature flags use [Unleash](https://docs.nais.io/services/feature-flagging/) via - Each service has `.nais/unleash.yaml` that provisions an API token as a Kubernetes secret - `envFrom` in `.nais/app.yaml` injects `UNLEASH_SERVER_API_URL`, `UNLEASH_SERVER_API_TOKEN`, `UNLEASH_SERVER_API_ENVIRONMENT` - Backend uses `io.getunleash:unleash-client-java`, frontend uses `unleash-client` (Node.js) -- When Unleash is unavailable, flags default to **enabled** (graceful degradation) +- When Unleash is unavailable, flags fall back to their configured default values (graceful degradation) **Current flags:** @@ -182,6 +182,6 @@ isEnabled(FEATURE_FLAGS.QUOTES_SUBMIT); **Rules:** - Register flag names as constants in `FeatureFlags.kt` / `unleash.ts` -- Always default to `true` so the app works without Unleash +- Define per-flag defaults (e.g., `quotes.submit` defaults `true`, `quotes.errors` defaults `false`) - Use `GET /api/features` (backend or frontend) to inspect flag states - Local Unleash admin UI: `http://localhost:4242` (started via `mise run infra:up`) diff --git a/README.md b/README.md index 222ee66..a03c523 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ This provides the environment variables `UNLEASH_SERVER_API_URL`, `UNLEASH_SERVE ### Local development -Unleash runs locally via docker-compose on port 4242. The admin UI is at (no login required with the dev setup). +Unleash runs locally via docker-compose on port 4242. The admin UI is at . Log in with the default credentials configured in `docker-compose.yaml` (username `admin`, password `unleash`). To create the `quotes.submit` toggle locally: @@ -175,10 +175,10 @@ The local client token `default:development.client-token` is pre-configured in b ### Graceful degradation -When Unleash is unavailable (no env vars set, or server unreachable), all feature flags default to **enabled**. This means: +When Unleash is unavailable (no env vars set, or server unreachable), feature flags fall back to their configured default values. These defaults are defined per flag in the backend/frontend and may be either enabled or disabled. This means: -- Tests run without Unleash — all features work normally -- A misconfigured Unleash connection won't break the application +- Tests can run without Unleash, using the configured default values for each flag +- A misconfigured Unleash connection won't break the application; features will follow their safe defaults ## License diff --git a/quotes-analytics/.mise.toml b/quotes-analytics/.mise.toml index 594928d..7cea770 100644 --- a/quotes-analytics/.mise.toml +++ b/quotes-analytics/.mise.toml @@ -80,6 +80,10 @@ run = "docker build -t quotes-analytics:local ." description = "Run Docker container locally" run = "docker run -p 8081:8081 --env-file .env.local quotes-analytics:local" +[tasks."security-scan"] +description = "Run security scanning" +run = "semgrep scan --config p/csharp --config p/owasp-top-ten --error ." + [tasks.ci] description = "Run all CI checks" run = [ @@ -87,6 +91,7 @@ run = [ "dotnet format quotes-analytics.sln --verify-no-changes", "dotnet build quotes-analytics.sln --no-restore /p:TreatWarningsAsErrors=true", "dotnet test quotes-analytics.sln --no-build --logger 'console;verbosity=normal'", + "mise run security-scan", ] [tasks."dependencies:check"] diff --git a/quotes-analytics/.nais/unleash.yaml b/quotes-analytics/.nais/unleash.yaml index 7fb3282..f510927 100644 --- a/quotes-analytics/.nais/unleash.yaml +++ b/quotes-analytics/.nais/unleash.yaml @@ -2,9 +2,9 @@ apiVersion: unleash.nais.io/v1 kind: ApiToken metadata: name: quotes-analytics - namespace: { { namespace } } + namespace: {{ namespace }} labels: - team: { { namespace } } + team: {{ namespace }} spec: unleashInstance: apiVersion: unleash.nais.io/v1 diff --git a/quotes-analytics/Dockerfile b/quotes-analytics/Dockerfile index 3845b7f..6b2845d 100644 --- a/quotes-analytics/Dockerfile +++ b/quotes-analytics/Dockerfile @@ -11,4 +11,5 @@ COPY --from=build /app/publish . EXPOSE 8081 ENV ASPNETCORE_URLS=http://+:8081 ENV DOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_HTTP2UNENCRYPTEDSUPPORT=true +USER app ENTRYPOINT ["dotnet", "quotes-analytics.dll"] diff --git a/quotes-backend/.mise.toml b/quotes-backend/.mise.toml index d857b7e..077bfbd 100644 --- a/quotes-backend/.mise.toml +++ b/quotes-backend/.mise.toml @@ -64,9 +64,13 @@ run = "docker build -t quotes-backend:local ." description = "Run Docker container locally" run = "docker run -p 8080:8080 quotes-backend:local" +[tasks."security-scan"] +description = "Run security scanning" +run = "semgrep scan --config p/kotlin --config p/owasp-top-ten --error ." + [tasks.ci] description = "Run all CI checks" -run = ["gradle clean", "gradle check", "gradle build"] +run = ["gradle clean", "gradle check", "gradle build", "mise run security-scan"] [tasks."dependencies:check"] description = "Check for outdated dependencies" diff --git a/quotes-backend/.nais/unleash.yaml b/quotes-backend/.nais/unleash.yaml index 74acbe8..644ce45 100644 --- a/quotes-backend/.nais/unleash.yaml +++ b/quotes-backend/.nais/unleash.yaml @@ -2,9 +2,9 @@ apiVersion: unleash.nais.io/v1 kind: ApiToken metadata: name: quotes-backend - namespace: { { namespace } } + namespace: {{ namespace }} labels: - team: { { namespace } } + team: {{ namespace }} spec: unleashInstance: apiVersion: unleash.nais.io/v1 diff --git a/quotes-backend/Dockerfile b/quotes-backend/Dockerfile index 14b8ea7..89f483d 100644 --- a/quotes-backend/Dockerfile +++ b/quotes-backend/Dockerfile @@ -21,5 +21,5 @@ FROM gcr.io/distroless/java21-debian12:nonroot # COPY --from=javaagent --chown=nonroot:nonroot /instrumentations/java/javaagent.jar /app/javaagent.jar COPY --from=build --chown=nonroot:nonroot /home/gradle/src/build/libs/quotes-backend-*.jar /app/quotes-backend.jar WORKDIR /app -# TLS Config works around an issue in OpenJDK... See: https://github.com/kubernetes-client/java/issues/854 +USER nonroot ENTRYPOINT [ "java", "-Djdk.tls.client.protocols=TLSv1.2", "-Dlogback.statusListenerClass=ch.qos.logback.core.status.NopStatusListener", "-jar", "/app/quotes-backend.jar" ] diff --git a/quotes-backend/src/main/kotlin/io/nais/quotesbackend/FeatureFlags.kt b/quotes-backend/src/main/kotlin/io/nais/quotesbackend/FeatureFlags.kt index bceced7..7e711da 100644 --- a/quotes-backend/src/main/kotlin/io/nais/quotesbackend/FeatureFlags.kt +++ b/quotes-backend/src/main/kotlin/io/nais/quotesbackend/FeatureFlags.kt @@ -29,7 +29,7 @@ object FeatureFlags { unleash = DefaultUnleash(config) log.info("Unleash initialized for environment: $environment") } catch (e: Exception) { - log.warn("Failed to initialize Unleash, feature flags will default to enabled: ${e.message}") + log.warn("Failed to initialize Unleash, feature flags will fall back to default values: ${e.message}") } } diff --git a/quotes-frontend/.mise.toml b/quotes-frontend/.mise.toml index 7c7731f..0cd2450 100644 --- a/quotes-frontend/.mise.toml +++ b/quotes-frontend/.mise.toml @@ -58,6 +58,10 @@ run = "docker run -p 3000:3000 quotes-frontend:local" description = "Run tests" run = "pnpm vitest run" +[tasks."security-scan"] +description = "Run security scanning" +run = "semgrep scan --config p/typescript --config p/owasp-top-ten --error ." + [tasks.ci] description = "Run all CI checks" run = [ @@ -66,6 +70,7 @@ run = [ "mise run type-check", "mise run test", "mise run build", + "mise run security-scan", ] [tasks."dependencies:check"] diff --git a/quotes-frontend/src/app/api/features/route.ts b/quotes-frontend/src/app/api/features/route.ts index fcf03a3..79423d1 100644 --- a/quotes-frontend/src/app/api/features/route.ts +++ b/quotes-frontend/src/app/api/features/route.ts @@ -3,7 +3,10 @@ import { isEnabled, FEATURE_FLAGS } from '@/utils/unleash'; export async function GET() { const features = Object.fromEntries( - Object.entries(FEATURE_FLAGS).map(([key, flag]) => [flag, isEnabled(flag)]) + Object.entries(FEATURE_FLAGS).map(([, flag]) => [ + flag, + isEnabled(flag as (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS]), + ]) ); return NextResponse.json(features); } diff --git a/quotes-frontend/src/utils/unleash.ts b/quotes-frontend/src/utils/unleash.ts index e1a9b2a..2afeb25 100644 --- a/quotes-frontend/src/utils/unleash.ts +++ b/quotes-frontend/src/utils/unleash.ts @@ -19,13 +19,23 @@ export function getUnleash(): Unleash | null { return unleash; } -export function isEnabled(flag: string, defaultValue = true): boolean { - const client = getUnleash(); - if (!client) return defaultValue; - return client.isEnabled(flag, undefined, defaultValue); -} - export const FEATURE_FLAGS = { QUOTES_SUBMIT: 'quotes.submit', QUOTES_ERRORS: 'quotes.errors', } as const; + +export type FeatureFlag = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS]; + +const FEATURE_FLAG_DEFAULTS: Record = { + [FEATURE_FLAGS.QUOTES_SUBMIT]: true, + [FEATURE_FLAGS.QUOTES_ERRORS]: false, +}; + +export function isEnabled(flag: FeatureFlag, defaultValue?: boolean): boolean { + const effectiveDefault = + defaultValue !== undefined ? defaultValue : FEATURE_FLAG_DEFAULTS[flag] ?? true; + + const client = getUnleash(); + if (!client) return effectiveDefault; + return client.isEnabled(flag, undefined, effectiveDefault); +} diff --git a/quotes-loadgen/.mise.toml b/quotes-loadgen/.mise.toml index c547fc6..a7b04ae 100644 --- a/quotes-loadgen/.mise.toml +++ b/quotes-loadgen/.mise.toml @@ -74,6 +74,10 @@ run = "docker build -t quotes-loadgen:local ." description = "Run Docker container locally" run = "docker run quotes-loadgen:local" +[tasks."security-scan"] +description = "Run security scanning" +run = "semgrep scan --config p/golang --config p/owasp-top-ten --error ." + [tasks.ci] description = "Run all CI checks" run = [ @@ -82,6 +86,7 @@ run = [ "go vet ./...", "go test ./...", "go build -o bin/quotes-loadgen ./cmd/main.go", + "mise run security-scan", ] [tasks."dependencies:check"] diff --git a/quotes-loadgen/internal/metrics/metrics.go b/quotes-loadgen/internal/metrics/metrics.go index 719f7a6..161a2d7 100644 --- a/quotes-loadgen/internal/metrics/metrics.go +++ b/quotes-loadgen/internal/metrics/metrics.go @@ -39,7 +39,7 @@ func Register() { func StartMetricsServer(port int) { http.Handle("/metrics", promhttp.Handler()) go func() { - if err := http.ListenAndServe(fmt.Sprintf(":%d", port), nil); err != nil { + if err := http.ListenAndServe(fmt.Sprintf(":%d", port), nil); err != nil { // nosemgrep: go.lang.security.audit.net.use-tls.use-tls // Server stopped, this is expected when shutting down fmt.Printf("Metrics server stopped: %v\n", err) } From e383b5697710ed752ee22e89692c91428f4bddc2 Mon Sep 17 00:00:00 2001 From: Hans Kristian Flaatten Date: Wed, 4 Mar 2026 10:51:59 +0100 Subject: [PATCH 7/7] fix: update semgrep entry format in .mise.toml for consistency --- .mise.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.mise.toml b/.mise.toml index f00de5d..100052f 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,7 +1,7 @@ [tools] # Root tools for the entire project (kept minimal, each service manages its own) watchexec = "2.3.2" -semgrep = "latest" +"pipx:semgrep" = "latest" # Note: ratchet can be installed separately with: go install github.com/sethvargo/ratchet@latest [env]