From afa8122a39cff342340872056ff42925606373e3 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:24:45 +0200 Subject: [PATCH] feat: custom-theme api (#2359) * feat: custom-theme api * fix: openapi schema * fix: review * refactor: schema validation --- .../status-page/__tests__/status-page.test.ts | 193 ++++++++++++++++++ .../rpc/handlers/status-page/converters.ts | 26 +++ .../routes/rpc/handlers/status-page/index.ts | 51 ++++- .../routes/rpc/handlers/status-page/limits.ts | 10 + apps/server/static/openapi.yaml | 64 ++++++ packages/db/src/schema/pages/validation.ts | 44 +++- .../openstatus/status_page/v1/service.proto | 8 + .../status_page/v1/status_page.proto | 12 ++ packages/proto/gen/openapi.yaml | 64 ++++++ .../openstatus/status_page/v1/service_pb.ts | 20 +- .../status_page/v1/status_page_pb.ts | 41 +++- packages/services/src/page/schemas.ts | 37 +--- 12 files changed, 524 insertions(+), 46 deletions(-) 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