diff --git a/.github/scripts/build-image/index.ts b/.github/scripts/build-image/index.ts
index ab86ce79f..6269daff4 100644
--- a/.github/scripts/build-image/index.ts
+++ b/.github/scripts/build-image/index.ts
@@ -115,16 +115,7 @@ async function main() {
console.log("Building image with following tags:");
tags.forEach((tag) => console.log(tag));
- const buildArgs = {
- ...(type === "web" ? { COMMIT_SHA: gitSha } : {}),
- ...(environment === "staging" &&
- type !== "leetcode-auth-stealer-service" &&
- type !== "leetcode-auth-service"
- ? {
- VITE_STAGING: true,
- }
- : {}),
- };
+ const buildArgs: Record = type === "web" ? { COMMIT_SHA: gitSha } : {};
const originalCwd = process.cwd();
try {
diff --git a/exclusions.ts b/exclusions.ts
index 830f73e64..cd4a64785 100644
--- a/exclusions.ts
+++ b/exclusions.ts
@@ -14,6 +14,7 @@ export const backendExclusions = [
export const frontendExclusions = [
`${frontendBaseDir}/**/*.test.ts`,
`${frontendBaseDir}/**/*.test.tsx`,
+ `${frontendBaseDir}/lib/test/**`,
`${frontendBaseDir}/lib/api/queries/**/*.ts`,
`${frontendBaseDir}/lib/api/schema/**/*.ts`,
];
diff --git a/infra/Dockerfile b/infra/Dockerfile
index c2b536d86..c742e99a6 100644
--- a/infra/Dockerfile
+++ b/infra/Dockerfile
@@ -12,8 +12,6 @@ RUN corepack enable pnpm && corepack use $(node -p "require('./package.json').pa
COPY js/ ./
-ARG VITE_STAGING
-ENV VITE_STAGING=${VITE_STAGING}
RUN pnpm run build
FROM mirror.gcr.io/library/node:22 AS email-build
diff --git a/js/README.md b/js/README.md
index c66e440da..daae8ab7f 100644
--- a/js/README.md
+++ b/js/README.md
@@ -31,7 +31,8 @@ js/
│ │ │ ├── schema # input validation schemas used in the frontend
│ │ │ ├── types # autogenerated types from OpenAPI schema
│ │ │ └── utils
-│ │ ├── ff # feature flags
+│ │ ├── config # backend-rendered runtime config (#codebloom-config)
+│ │ ├── ff # feature flags (read from runtime config)
│ │ ├── helper # stateless helper functions
│ │ ├── hooks # custom re-usable react hooks
│ │ ├── queryProvider.tsx # ReactQueryProvider
@@ -125,6 +126,32 @@ which allows us to define a complex relationship of parent tags to child tags. T
+### Feature flags
+
+UI feature flags live in the backend config under `ff.ui` and are handed to the frontend in `index.html` at page load, so [`src/lib/ff`](./src/lib/ff/) exposes them as plain booleans: no request, no loading state. They are separate from the backend `ff.*` flags enforced by `@FF`; a UI flag only controls visibility.
+
+Changing a value takes a backend restart and a page reload, not a frontend rebuild.
+
+#### Adding a UI flag
+
+1. Add the key under `ff.ui` in [`application.yml`](../src/main/resources/application.yml), and override it in the profile files (`application-dev.yaml`, `application-ci.yaml`) as needed. No Java changes are required.
+ ```yaml
+ ff:
+ ui:
+ myFeature: false
+ ```
+ > [!IMPORTANT]
+ > The key reaches the frontend exactly as written, so use the camelCase name the frontend reads. Set it in YAML, not via an environment variable: env var names are uppercase and would arrive as a different key.
+2. Regenerate types by running the backend with the `dev` profile (or `just type-gen`). This adds the key to `UiFeatureFlags` in `complex.ts`.
+3. Export it from `src/lib/ff`:
+ ```ts
+ export const myFeatureFF = runtimeConfig.featureFlags.myFeature;
+ ```
+4. If the feature has backend endpoints, gate them with `@FF` as well.
+
+> [!NOTE]
+> `pnpm run dev` needs the backend running (`CODEBLOOM_BACKEND_URL`, default `http://localhost:8080`) to supply the flags; without it Vite returns an error rather than guessing values.
+
### ApiURL
You can view the implementation of `ApiURL` [here](./src/lib/api/common/apiURL.ts).
diff --git a/js/index.html b/js/index.html
index e29af8ec1..1e824dbff 100644
--- a/js/index.html
+++ b/js/index.html
@@ -16,6 +16,9 @@
/>
+
diff --git a/js/runtime-config.ts b/js/runtime-config.ts
new file mode 100644
index 000000000..8098f0f3e
--- /dev/null
+++ b/js/runtime-config.ts
@@ -0,0 +1,70 @@
+import type { Plugin } from "vite";
+
+import { readFile } from "node:fs/promises";
+import { resolve } from "node:path";
+
+const placeholder = "__CODEBLOOM_CONFIG__";
+
+/**
+ * Create codebloom-config by calling the Java backend to get fully transformed index.html
+ * */
+export function runtimeConfigPlugin(backendUrl: string): Plugin {
+ async function inject(html: string): Promise {
+ const response = await fetch(new URL("/index.html", backendUrl), {
+ signal: AbortSignal.timeout(5000),
+ });
+ if (!response.ok) {
+ throw new Error(`Backend HTML bootstrap failed: HTTP ${response.status}`);
+ }
+
+ const backendHtml = await response.text();
+ const config = backendHtml.match(
+ /";
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ private static FeatureFlagManager flags() {
+ FeatureFlagConfiguration ff = new FeatureFlagConfiguration();
+ ff.setDuels(true);
+ ff.setUserMetrics(true);
+ ff.getUi().putAll(Map.of("duels", false, "userMetrics", false, "notInJava", true));
+ return new FeatureFlagManager(ff);
+ }
+
+ private static ByteArrayResource html(final String body) {
+ return new ByteArrayResource(body.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private String render(final String template) throws Exception {
+ var controller = new ApplicationShellController(flags(), objectMapper, html(template));
+ return new String(controller.index().getBody(), StandardCharsets.UTF_8);
+ }
+
+ @Test
+ void passesUiFlagsThroughIndependentlyOfBackendFlags() throws Exception {
+ String body = render(OPEN + "__CODEBLOOM_CONFIG__" + CLOSE);
+ String json = body.substring(OPEN.length(), body.length() - CLOSE.length());
+
+ // Backend duels/userMetrics are on; the browser sees only ff.ui, including keys Java never declares.
+ assertEquals(
+ objectMapper.readTree("{\"featureFlags\":{\"duels\":false,\"userMetrics\":false,\"notInJava\":true}}"),
+ objectMapper.readTree(json));
+ }
+
+ @Test
+ void refusesTemplateWithoutConfigPlaceholder() {
+ assertThrows(
+ IllegalStateException.class,
+ () -> new ApplicationShellController(flags(), objectMapper, html("")));
+ }
+}