diff --git a/apps/server/src/routes/rpc/handlers/status-page/__tests__/status-page.test.ts b/apps/server/src/routes/rpc/handlers/status-page/__tests__/status-page.test.ts
index d153fd31..801187e3 100644
--- a/apps/server/src/routes/rpc/handlers/status-page/__tests__/status-page.test.ts
+++ b/apps/server/src/routes/rpc/handlers/status-page/__tests__/status-page.test.ts
@@ -67,6 +67,9 @@ beforeAll(async () => {
await db.run(
sql`UPDATE workspace SET limits = json_set(COALESCE(limits, '{}'), '$."status-subscribers"', json('true')) WHERE id = 1`,
);
+ await db.run(
+ sql`UPDATE workspace SET limits = json_set(COALESCE(limits, '{}'), '$."custom-theme"', json('true')) WHERE id = 1`,
+ );
// Clean up any existing test data
await db
@@ -427,6 +430,45 @@ describe("StatusPageService.CreateStatusPage", () => {
await db.delete(page).where(eq(page.id, firstPage.id));
}
});
+
+ test("creates a status page with a custom theme", async () => {
+ const res = await connectRequest(
+ "CreateStatusPage",
+ {
+ title: `${TEST_PREFIX}-custom-theme`,
+ slug: `${TEST_PREFIX}-custom-theme-slug`,
+ customTheme: { light: { "--primary": "hsl(24 94% 50%)" } },
+ },
+ { "x-openstatus-key": "1" },
+ );
+
+ expect(res.status).toBe(200);
+
+ const data = await res.json();
+ expect(data.statusPage.customTheme?.light).toEqual({
+ "--primary": "hsl(24 94% 50%)",
+ });
+
+ // Clean up
+ await db.delete(page).where(eq(page.id, Number(data.statusPage.id)));
+ });
+
+ test("returns 403 when creating with custom theme on free plan", async () => {
+ const res = await connectRequest(
+ "CreateStatusPage",
+ {
+ title: `${TEST_PREFIX}-custom-theme-denied`,
+ slug: `${TEST_PREFIX}-custom-theme-denied-slug`,
+ customTheme: { light: { "--primary": "red" } },
+ },
+ { "x-openstatus-key": "2" },
+ );
+
+ expect(res.status).toBe(403);
+
+ const data = await res.json();
+ expect(data.message).toContain("Upgrade for custom theme");
+ });
});
describe("StatusPageService.GetStatusPage", () => {
@@ -705,6 +747,157 @@ describe("StatusPageService.UpdateStatusPage", () => {
.set({ defaultLocale: "en", locales: null })
.where(eq(page.id, testPageToUpdateId));
});
+
+ test("updates and clears the custom theme", async () => {
+ const res = await connectRequest(
+ "UpdateStatusPage",
+ {
+ id: String(testPageToUpdateId),
+ customTheme: {
+ light: { "--primary": "hsl(24 94% 50%)" },
+ dark: { "--background": "oklch(0.2 0 0)" },
+ },
+ },
+ { "x-openstatus-key": "1" },
+ );
+
+ expect(res.status).toBe(200);
+
+ const data = await res.json();
+ expect(data.statusPage.customTheme?.light).toEqual({
+ "--primary": "hsl(24 94% 50%)",
+ });
+ expect(data.statusPage.customTheme?.dark).toEqual({
+ "--background": "oklch(0.2 0 0)",
+ });
+
+ // Empty message clears the stored overrides
+ const clearRes = await connectRequest(
+ "UpdateStatusPage",
+ {
+ id: String(testPageToUpdateId),
+ customTheme: {},
+ },
+ { "x-openstatus-key": "1" },
+ );
+
+ expect(clearRes.status).toBe(200);
+
+ const clearData = await clearRes.json();
+ expect(clearData.statusPage.customTheme).toBeUndefined();
+ });
+
+ test("keeps the custom theme when the field is omitted or null", async () => {
+ await db
+ .update(page)
+ .set({ customTheme: { light: { "--primary": "red" } } })
+ .where(eq(page.id, testPageToUpdateId));
+
+ try {
+ // Omitted field — unrelated update must not touch the stored theme
+ const omitRes = await connectRequest(
+ "UpdateStatusPage",
+ {
+ id: String(testPageToUpdateId),
+ title: `${TEST_PREFIX}-page-to-update`,
+ },
+ { "x-openstatus-key": "1" },
+ );
+
+ expect(omitRes.status).toBe(200);
+
+ const omitData = await omitRes.json();
+ expect(omitData.statusPage.customTheme?.light).toEqual({
+ "--primary": "red",
+ });
+
+ // Explicit null — proto3 JSON treats null as absent, so it keeps too
+ const nullRes = await connectRequest(
+ "UpdateStatusPage",
+ {
+ id: String(testPageToUpdateId),
+ customTheme: null,
+ },
+ { "x-openstatus-key": "1" },
+ );
+
+ expect(nullRes.status).toBe(200);
+
+ const nullData = await nullRes.json();
+ expect(nullData.statusPage.customTheme?.light).toEqual({
+ "--primary": "red",
+ });
+ } finally {
+ await db
+ .update(page)
+ .set({ customTheme: null })
+ .where(eq(page.id, testPageToUpdateId));
+ }
+ });
+
+ test("rejects unsafe custom theme values", async () => {
+ for (const value of [
+ "",
+ "red;} body { background: red",
+ "",
+ ]) {
+ const res = await connectRequest(
+ "UpdateStatusPage",
+ {
+ id: String(testPageToUpdateId),
+ customTheme: { light: { "--primary": value } },
+ },
+ { "x-openstatus-key": "1" },
+ );
+
+ expect(res.status).toBe(400);
+ }
+ });
+
+ test("rejects unknown custom theme variables", async () => {
+ const res = await connectRequest(
+ "UpdateStatusPage",
+ {
+ id: String(testPageToUpdateId),
+ customTheme: { light: { "--not-a-var": "red" } },
+ },
+ { "x-openstatus-key": "1" },
+ );
+
+ expect(res.status).toBe(400);
+ });
+
+ test("returns 403 when updating custom theme on free plan", async () => {
+ const freePage = await db
+ .insert(page)
+ .values({
+ workspaceId: 2,
+ title: `${TEST_PREFIX}-free-theme`,
+ slug: `${TEST_PREFIX}-free-theme-slug`,
+ description: "",
+ customDomain: "",
+ })
+ .returning()
+ .get();
+
+ try {
+ const res = await connectRequest(
+ "UpdateStatusPage",
+ {
+ id: String(freePage.id),
+ customTheme: { light: { "--primary": "red" } },
+ },
+ { "x-openstatus-key": "2" },
+ );
+
+ expect(res.status).toBe(403);
+
+ const data = await res.json();
+ expect(data.message).toContain("Upgrade for custom theme");
+ } finally {
+ await db.delete(page).where(eq(page.id, freePage.id));
+ }
+ });
});
// ==========================================================================
diff --git a/apps/server/src/routes/rpc/handlers/status-page/converters.ts b/apps/server/src/routes/rpc/handlers/status-page/converters.ts
index 5452c018..7c29b732 100644
--- a/apps/server/src/routes/rpc/handlers/status-page/converters.ts
+++ b/apps/server/src/routes/rpc/handlers/status-page/converters.ts
@@ -1,5 +1,6 @@
import type { Locale } from "@openstatus/locales";
import type {
+ CustomTheme,
PageComponent,
PageComponentGroup,
PageSubscriber,
@@ -18,6 +19,10 @@ import {
Locale as ProtoLocale,
SubscriberSource,
} from "@openstatus/proto/status_page/v1";
+import {
+ type CustomTheme as DbCustomTheme,
+ hasCustomTheme,
+} from "@openstatus/theme-store";
/**
* Database types
@@ -40,6 +45,7 @@ type DBPage = {
defaultLocale: Locale;
locales: Locale[] | null;
allowIndex: boolean;
+ customTheme?: DbCustomTheme | null;
createdAt: Date | null;
updatedAt: Date | null;
};
@@ -304,6 +310,26 @@ export function dbPageToProto(page: DBPage): StatusPage {
authEmailDomains: page.authEmailDomains?.split(",").filter(Boolean) ?? [],
allowIndex: page.allowIndex ?? true,
allowedIpRanges: page.allowedIpRanges ?? "",
+ customTheme: dbCustomThemeToProto(page.customTheme),
+ };
+}
+
+export function dbCustomThemeToProto(
+ customTheme: DbCustomTheme | null | undefined,
+): CustomTheme | undefined {
+ if (!hasCustomTheme(customTheme)) return undefined;
+ // ThemeVars values are `string | undefined`; proto maps require `string`.
+ const pick = (vars?: Record) => {
+ const out: Record = {};
+ for (const [name, value] of Object.entries(vars ?? {})) {
+ if (typeof value === "string") out[name] = value;
+ }
+ return out;
+ };
+ return {
+ $typeName: "openstatus.status_page.v1.CustomTheme" as const,
+ light: pick(customTheme.light),
+ dark: pick(customTheme.dark),
};
}
diff --git a/apps/server/src/routes/rpc/handlers/status-page/index.ts b/apps/server/src/routes/rpc/handlers/status-page/index.ts
index f16f314e..82f9a3a4 100644
--- a/apps/server/src/routes/rpc/handlers/status-page/index.ts
+++ b/apps/server/src/routes/rpc/handlers/status-page/index.ts
@@ -27,6 +27,7 @@ import {
import type {
ComponentDayBucket,
ComponentEvent,
+ CustomTheme,
GetPageComponentDailySummaryResponse,
PageComponentDailySummary,
StatusPageService,
@@ -50,6 +51,7 @@ import {
listPages,
updatePageAppearance,
updatePageCustomDomain,
+ updatePageCustomTheme,
updatePageGeneral,
updatePageLinks,
updatePageLocales,
@@ -64,7 +66,11 @@ import {
upsertSelfSignupSubscriber,
} from "@openstatus/services/page-subscriber";
import { getChannel } from "@openstatus/subscriptions";
-import { THEME_KEYS, type ThemeKey } from "@openstatus/theme-store";
+import {
+ THEME_KEYS,
+ type ThemeKey,
+ validateCustomTheme,
+} from "@openstatus/theme-store";
import { toConnectError, toServiceCtx } from "../../adapter";
import { getRpcContext } from "../../interceptors";
@@ -107,6 +113,7 @@ import {
} from "./errors";
import {
checkCustomDomainLimit,
+ checkCustomThemeLimit,
checkEmailDomainProtectionLimit,
checkIpRestrictionLimit,
checkNoIndexLimit,
@@ -244,6 +251,24 @@ function validateAllowedIpRanges(ranges: string): string[] {
return normalized;
}
+// Proto map values arrive unvalidated; check var names / safe values at the
+// handler so callers get a readable InvalidArgument instead of the service's
+// raw zod message.
+function validateProtoCustomTheme(customTheme: CustomTheme): {
+ light: Record;
+ dark: Record;
+} {
+ const input = {
+ light: { ...customTheme.light },
+ dark: { ...customTheme.dark },
+ };
+ const result = validateCustomTheme(input);
+ if (!result.valid) {
+ throw new ConnectError(result.errors.join(" "), Code.InvalidArgument);
+ }
+ return input;
+}
+
/**
* Helper to get a component by ID with workspace scope.
*/
@@ -397,6 +422,12 @@ export const statusPageServiceImpl: ServiceImpl = {
checkNoIndexLimit(limits);
}
+ let customTheme: ReturnType | undefined;
+ if (req.customTheme !== undefined) {
+ checkCustomThemeLimit(limits);
+ customTheme = validateProtoCustomTheme(req.customTheme);
+ }
+
// `published` relies on DB default (false). The service's
// CreatePageInput type doesn't surface the column, and its behavior
// matches the legacy `published: false` write on create.
@@ -425,6 +456,7 @@ export const statusPageServiceImpl: ServiceImpl = {
defaultLocale,
locales,
allowIndex,
+ customTheme,
},
}).catch((err) => {
// Same handler-layer remap as the `i18n` pre-check above —
@@ -639,6 +671,16 @@ export const statusPageServiceImpl: ServiceImpl = {
req.customDomain !== undefined ? req.customDomain : undefined;
const accessChanged = hasAccessType || hasAllowIndex;
+ // Omitted = keep; empty message = clear (the service maps an empty
+ // var set to null).
+ let customThemeForUpdate:
+ | ReturnType
+ | undefined;
+ if (req.customTheme !== undefined) {
+ checkCustomThemeLimit(limits);
+ customThemeForUpdate = validateProtoCustomTheme(req.customTheme);
+ }
+
// Wrap all per-section updates in a single transaction so partial
// failures don't leave the page in a half-updated state. Each
// per-section service call's internal `withTransaction` detects
@@ -734,6 +776,13 @@ export const statusPageServiceImpl: ServiceImpl = {
});
}
+ if (customThemeForUpdate !== undefined) {
+ await updatePageCustomTheme({
+ ctx: txCtx,
+ input: { id: pageId, customTheme: customThemeForUpdate },
+ });
+ }
+
if (localesChanged) {
await updatePageLocales({
ctx: txCtx,
diff --git a/apps/server/src/routes/rpc/handlers/status-page/limits.ts b/apps/server/src/routes/rpc/handlers/status-page/limits.ts
index 2ff3b262..86c5f72a 100644
--- a/apps/server/src/routes/rpc/handlers/status-page/limits.ts
+++ b/apps/server/src/routes/rpc/handlers/status-page/limits.ts
@@ -86,6 +86,16 @@ export function checkNoIndexLimit(limits: Limits): void {
}
}
+/**
+ * Check if the custom theme feature is available on the workspace plan.
+ * Throws ConnectError with PermissionDenied if not available.
+ */
+export function checkCustomThemeLimit(limits: Limits): void {
+ if (!limits["custom-theme"]) {
+ throw new ConnectError("Upgrade for custom theme", Code.PermissionDenied);
+ }
+}
+
export function checkStatusSubscribersLimit(limits: Limits): void {
if (!limits["status-subscribers"]) {
throw new ConnectError(
diff --git a/apps/server/static/openapi.yaml b/apps/server/static/openapi.yaml
index 820448a7..375b2872 100644
--- a/apps/server/static/openapi.yaml
+++ b/apps/server/static/openapi.yaml
@@ -2745,6 +2745,14 @@ components:
- "null"
title: allowed_ip_ranges
description: Comma-separated IPv4 CIDR ranges (required when access_type is IP_RESTRICTED).
+ customTheme:
+ oneOf:
+ - $ref: '#/components/schemas/openstatus.status_page.v1.CustomTheme'
+ - type: "null"
+ title: custom_theme
+ description: |-
+ Per-mode CSS variable overrides merged over the theme (optional).
+ Only supported variable names are accepted. Requires the custom-theme plan feature.
title: CreateStatusPageRequest
additionalProperties: false
description: CreateStatusPageRequest is the request to create a new status page.
@@ -2758,6 +2766,48 @@ components:
title: CreateStatusPageResponse
additionalProperties: false
description: CreateStatusPageResponse is the response after creating a status page.
+ openstatus.status_page.v1.CustomTheme:
+ type: object
+ properties:
+ light:
+ type: object
+ title: light
+ additionalProperties:
+ type: string
+ title: value
+ description: 'CSS variable overrides applied in light mode, keyed by variable name (e.g. "--primary": "hsl(24 94% 50%)").'
+ dark:
+ type: object
+ title: dark
+ additionalProperties:
+ type: string
+ title: value
+ description: CSS variable overrides applied in dark mode, keyed by variable name.
+ title: CustomTheme
+ additionalProperties: false
+ description: CustomTheme holds per-mode CSS variable overrides merged over the page theme.
+ openstatus.status_page.v1.CustomTheme.DarkEntry:
+ type: object
+ properties:
+ key:
+ type: string
+ title: key
+ value:
+ type: string
+ title: value
+ title: DarkEntry
+ additionalProperties: false
+ openstatus.status_page.v1.CustomTheme.LightEntry:
+ type: object
+ properties:
+ key:
+ type: string
+ title: key
+ value:
+ type: string
+ title: value
+ title: LightEntry
+ additionalProperties: false
openstatus.status_page.v1.DeleteComponentGroupRequest:
type: object
properties:
@@ -3419,6 +3469,12 @@ components:
type: string
title: allowed_ip_ranges
description: Comma-separated IPv4 CIDR ranges (only set when access_type is IP_RESTRICTED).
+ customTheme:
+ oneOf:
+ - $ref: '#/components/schemas/openstatus.status_page.v1.CustomTheme'
+ - type: "null"
+ title: custom_theme
+ description: Per-mode CSS variable overrides merged over the theme (only set when configured).
title: StatusPage
additionalProperties: false
description: StatusPage represents a full status page with all details.
@@ -3734,6 +3790,14 @@ components:
- "null"
title: allowed_ip_ranges
description: Comma-separated IPv4 CIDR ranges (required when access_type is IP_RESTRICTED).
+ customTheme:
+ oneOf:
+ - $ref: '#/components/schemas/openstatus.status_page.v1.CustomTheme'
+ - type: "null"
+ title: custom_theme
+ description: |-
+ New per-mode CSS variable overrides (optional). Omit to keep the current
+ value; send an empty message to clear. Requires the custom-theme plan feature.
title: UpdateStatusPageRequest
additionalProperties: false
description: UpdateStatusPageRequest is the request to update a status page.
diff --git a/packages/db/src/schema/pages/validation.ts b/packages/db/src/schema/pages/validation.ts
index 765bca7b..0aa0511e 100644
--- a/packages/db/src/schema/pages/validation.ts
+++ b/packages/db/src/schema/pages/validation.ts
@@ -1,6 +1,11 @@
import { locales } from "@openstatus/locales";
import type { ThemeKey } from "@openstatus/theme-store";
-import { THEME_KEYS } from "@openstatus/theme-store";
+import {
+ hasCustomTheme,
+ sanitizeCustomTheme,
+ THEME_KEYS,
+ validateCustomTheme,
+} from "@openstatus/theme-store";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import { z } from "zod";
@@ -35,6 +40,33 @@ const stringToArray = z.preprocess((val) => {
return [];
}, z.array(z.string()));
+// Loose { light, dark } var maps — write paths enforce the supported var
+// names / safe values; `.catch(null)` degrades corrupt stored json to "no
+// overrides" instead of tanking every page read.
+const themeVarsSchema = z.record(z.string(), z.string());
+export const customThemeSchema = z.object({
+ light: themeVarsSchema.optional(),
+ dark: themeVarsSchema.optional(),
+});
+
+// Strict write-side counterpart: only supported var names and values that
+// can't break out of the inline