Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,9 @@ afterAll(async () => {
await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-slug-to-delete`));
await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-slug-to-update`));
await db.delete(page).where(eq(page.slug, `${TEST_PREFIX}-created-slug`));
await db
.delete(page)
.where(eq(page.slug, `${TEST_PREFIX}-locale-create-slug`));

await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-monitor`));
});
Expand Down Expand Up @@ -542,6 +545,134 @@ describe("StatusPageService.UpdateStatusPage", () => {

expect(res.status).toBe(409);
});

test("updates locale settings", async () => {
const res = await connectRequest(
"UpdateStatusPage",
{
id: String(testPageToUpdateId),
defaultLocale: "LOCALE_FR",
locales: ["LOCALE_EN", "LOCALE_FR", "LOCALE_DE"],
},
{ "x-openstatus-key": "1" },
);

expect(res.status).toBe(200);

const data = await res.json();
expect(data.statusPage.defaultLocale).toBe("LOCALE_FR");
expect(data.statusPage.locales).toEqual([
"LOCALE_EN",
"LOCALE_FR",
"LOCALE_DE",
]);

// Restore defaults
await db
.update(page)
.set({ defaultLocale: "en", locales: null })
.where(eq(page.id, testPageToUpdateId));
});
});

// ==========================================================================
// Locale Support
// ==========================================================================

describe("StatusPageService locale fields", () => {
test("creates a page with locale settings", async () => {
const res = await connectRequest(
"CreateStatusPage",
{
title: `${TEST_PREFIX}-locale-create`,
slug: `${TEST_PREFIX}-locale-create-slug`,
defaultLocale: "LOCALE_DE",
locales: ["LOCALE_EN", "LOCALE_DE"],
},
{ "x-openstatus-key": "1" },
);

expect(res.status).toBe(200);

const data = await res.json();
expect(data.statusPage.defaultLocale).toBe("LOCALE_DE");
expect(data.statusPage.locales).toEqual(["LOCALE_EN", "LOCALE_DE"]);

// Clean up
await db.delete(page).where(eq(page.id, Number(data.statusPage.id)));
});

test("creates a page with default locale when none specified", async () => {
const res = await connectRequest(
"CreateStatusPage",
{
title: `${TEST_PREFIX}-locale-default`,
slug: `${TEST_PREFIX}-locale-create-slug`,
},
{ "x-openstatus-key": "1" },
);

expect(res.status).toBe(200);

const data = await res.json();
expect(data.statusPage.defaultLocale).toBe("LOCALE_EN");
// ConnectRPC omits empty repeated fields in JSON
expect(data.statusPage.locales ?? []).toEqual([]);

// Clean up
await db.delete(page).where(eq(page.id, Number(data.statusPage.id)));
});

test("returns locale fields in GetStatusPage", async () => {
// Set locale on test page
await db
.update(page)
.set({ defaultLocale: "fr", locales: ["en", "fr"] })
.where(eq(page.id, testPageId));

const res = await connectRequest(
"GetStatusPage",
{ id: String(testPageId) },
{ "x-openstatus-key": "1" },
);

expect(res.status).toBe(200);

const data = await res.json();
expect(data.statusPage.defaultLocale).toBe("LOCALE_FR");
expect(data.statusPage.locales).toEqual(["LOCALE_EN", "LOCALE_FR"]);

// Restore defaults
await db
.update(page)
.set({ defaultLocale: "en", locales: null })
.where(eq(page.id, testPageId));
});

test("returns locale fields in GetStatusPageContent", async () => {
await db
.update(page)
.set({ defaultLocale: "de", locales: ["en", "de"] })
.where(eq(page.id, testPageId));

const res = await connectRequest(
"GetStatusPageContent",
{ slug: testPageSlug },
{ "x-openstatus-key": "1" },
);

expect(res.status).toBe(200);

const data = await res.json();
expect(data.statusPage.defaultLocale).toBe("LOCALE_DE");
expect(data.statusPage.locales).toEqual(["LOCALE_EN", "LOCALE_DE"]);

// Restore defaults
await db
.update(page)
.set({ defaultLocale: "en", locales: null })
.where(eq(page.id, testPageId));
});
});

describe("StatusPageService.DeleteStatusPage", () => {
Expand Down
38 changes: 38 additions & 0 deletions apps/server/src/routes/rpc/services/status-page/converters.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Locale } from "@openstatus/locales";
import type {
PageComponent,
PageComponentGroup,
Expand All @@ -10,6 +11,7 @@ import {
PageAccessType,
PageComponentType,
PageTheme,
Locale as ProtoLocale,
} from "@openstatus/proto/status_page/v1";

/**
Expand All @@ -27,6 +29,8 @@ type DBPage = {
homepageUrl: string | null;
contactUrl: string | null;
icon: string | null;
defaultLocale: Locale;
locales: Locale[] | null;
createdAt: Date | null;
updatedAt: Date | null;
};
Expand Down Expand Up @@ -163,6 +167,38 @@ export function protoComponentTypeToDb(
}
}

/**
* Convert DB locale string to proto enum.
*/
export function dbLocaleToProto(locale: Locale): ProtoLocale {
switch (locale) {
case "en":
return ProtoLocale.EN;
case "fr":
return ProtoLocale.FR;
case "de":
return ProtoLocale.DE;
default:
return ProtoLocale.EN;
}
}

/**
* Convert proto locale enum to DB string.
*/
export function protoLocaleToDb(locale: ProtoLocale): Locale {
switch (locale) {
case ProtoLocale.EN:
return "en";
case ProtoLocale.FR:
return "fr";
case ProtoLocale.DE:
return "de";
default:
return "en";
}
}

/**
* Convert a DB status page to full proto format.
*/
Expand All @@ -182,6 +218,8 @@ export function dbPageToProto(page: DBPage): StatusPage {
icon: page.icon ?? "",
createdAt: page.createdAt?.toISOString() ?? "",
updatedAt: page.updatedAt?.toISOString() ?? "",
defaultLocale: dbLocaleToProto(page.defaultLocale),
locales: page.locales?.map(dbLocaleToProto) ?? [],
};
}

Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/routes/rpc/services/status-page/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
dbPageToProto,
dbPageToProtoSummary,
dbSubscriberToProto,
protoLocaleToDb,
} from "./converters";
import {
componentGroupCreateFailedError,
Expand Down Expand Up @@ -169,6 +170,11 @@ export const statusPageServiceImpl: ServiceImpl<typeof StatusPageService> = {
published: false,
homepageUrl: req.homepageUrl ?? null,
contactUrl: req.contactUrl ?? null,
defaultLocale: req.defaultLocale
? protoLocaleToDb(req.defaultLocale)
: "en",
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In createStatusPage, req.defaultLocale ? ... : "en" will treat Locale.UNSPECIFIED (enum value 0) as falsy and silently coerce it to "en". Use an explicit undefined check (or handle UNSPECIFIED) so an explicitly provided enum value doesn’t change behavior due to JS truthiness.

Suggested change
defaultLocale: req.defaultLocale
? protoLocaleToDb(req.defaultLocale)
: "en",
defaultLocale:
req.defaultLocale !== undefined
? protoLocaleToDb(req.defaultLocale)
: "en",

Copilot uses AI. Check for mistakes.
locales:
req.locales.length > 0 ? req.locales.map(protoLocaleToDb) : null,
})
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CreateStatusPage also writes defaultLocale/locales without enforcing that the default is contained in the enabled locales list when locales is provided. Consider validating or auto-including the default locale to avoid persisting an inconsistent configuration.

Copilot uses AI. Check for mistakes.
.returning()
.get();
Expand Down Expand Up @@ -275,6 +281,12 @@ export const statusPageServiceImpl: ServiceImpl<typeof StatusPageService> = {
if (req.contactUrl !== undefined) {
updateValues.contactUrl = req.contactUrl || null;
}
if (req.defaultLocale !== undefined) {
updateValues.defaultLocale = protoLocaleToDb(req.defaultLocale);
}
if (req.locales.length > 0) {
updateValues.locales = req.locales.map(protoLocaleToDb);
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UpdateStatusPage only updates locales when req.locales.length > 0, which makes it impossible for clients to clear/reset locales (e.g., set it to empty/null) via the API. If clearing is a supported operation (DB uses null for “no locales configured”), consider adding an explicit way to clear it (e.g., treat an empty list as “clear”, or add a separate boolean/field-mask-style signal).

Suggested change
if (req.locales.length > 0) {
updateValues.locales = req.locales.map(protoLocaleToDb);
if (req.locales !== undefined) {
updateValues.locales =
req.locales.length > 0 ? req.locales.map(protoLocaleToDb) : null;

Copilot uses AI. Check for mistakes.
}
Comment on lines +300 to +321
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Locale fields are written without validating the invariant that defaultLocale must be included in locales when locales is provided (this is enforced in other code paths via packages/db/src/schema/pages/validation.ts). As-is, UpdateStatusPage can persist inconsistent combinations when updating either field independently; add server-side validation/normalization before saving.

Copilot uses AI. Check for mistakes.

const updatedPage = await db
.update(page)
Expand Down
12 changes: 12 additions & 0 deletions packages/proto/api/openstatus/status_page/v1/service.proto
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,12 @@ message CreateStatusPageRequest {

// URL to the contact page (optional).
optional string contact_url = 5;

// Default locale for the status page (optional, defaults to EN).
optional Locale default_locale = 6;

// Enabled locales for the status page (optional).
repeated Locale locales = 7;
}

// CreateStatusPageResponse is the response after creating a status page.
Expand Down Expand Up @@ -185,6 +191,12 @@ message UpdateStatusPageRequest {

// New contact URL (optional).
optional string contact_url = 6;

// New default locale for the status page (optional).
optional Locale default_locale = 7;

// New enabled locales for the status page (optional).
repeated Locale locales = 8;
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Switching from optional LocaleList to repeated Locale in an update message loses the ability to distinguish "field not sent" (don't change) from "field sent empty" (clear locales). In proto3, a repeated field has no presence — both cases deserialize to an empty list. This means an update request that omits locales entirely will be interpreted the same as one that explicitly clears them.

Consider keeping a wrapper message (e.g., optional LocaleList) or using a field mask to preserve patch semantics, consistent with the optional pattern used by every other field in this message.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/proto/api/openstatus/status_page/v1/service.proto, line 199:

<comment>Switching from `optional LocaleList` to `repeated Locale` in an update message loses the ability to distinguish "field not sent" (don't change) from "field sent empty" (clear locales). In proto3, a `repeated` field has no presence — both cases deserialize to an empty list. This means an update request that omits `locales` entirely will be interpreted the same as one that explicitly clears them.

Consider keeping a wrapper message (e.g., `optional LocaleList`) or using a field mask to preserve patch semantics, consistent with the `optional` pattern used by every other field in this message.</comment>

<file context>
@@ -196,8 +196,7 @@ message UpdateStatusPageRequest {
   // New enabled locales for the status page (optional).
-  // When absent: don't change. When present but empty: clear locales. When present with values: set locales.
-  optional LocaleList locales = 8;
+  repeated Locale locales = 8;
 }
 
</file context>
Fix with Cubic

}

// UpdateStatusPageResponse is the response after updating a status page.
Expand Down
14 changes: 14 additions & 0 deletions packages/proto/api/openstatus/status_page/v1/status_page.proto
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ enum PageTheme {
PAGE_THEME_DARK = 3;
}

// Locale defines the supported languages for a status page.
enum Locale {
LOCALE_UNSPECIFIED = 0;
LOCALE_EN = 1;
LOCALE_FR = 2;
LOCALE_DE = 3;
}

// OverallStatus represents the aggregated status of all components on a page.
enum OverallStatus {
OVERALL_STATUS_UNSPECIFIED = 0;
Expand Down Expand Up @@ -81,6 +89,12 @@ message StatusPage {
string updated_at = 13 [
(gnostic.openapi.v3.property) = {example: {yaml: "2024-06-20T14:30:00Z"}}
];

// Default locale for the status page.
Locale default_locale = 14;

// Enabled locales for the status page.
repeated Locale locales = 15;
}

// StatusPageSummary represents metadata for a status page (used in list responses).
Expand Down
32 changes: 30 additions & 2 deletions packages/proto/gen/ts/openstatus/status_page/v1/service_pb.ts

Large diffs are not rendered by default.

51 changes: 49 additions & 2 deletions packages/proto/gen/ts/openstatus/status_page/v1/status_page_pb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file openstatus/status_page/v1/status_page.proto.
*/
export const file_openstatus_status_page_v1_status_page: GenFile = /*@__PURE__*/
fileDesc("CitvcGVuc3RhdHVzL3N0YXR1c19wYWdlL3YxL3N0YXR1c19wYWdlLnByb3RvEhlvcGVuc3RhdHVzLnN0YXR1c19wYWdlLnYxIrEDCgpTdGF0dXNQYWdlEgoKAmlkGAEgASgJEg0KBXRpdGxlGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJEh4KBHNsdWcYBCABKAlCELpHDToLEglhY21lLWNvcnASMAoNY3VzdG9tX2RvbWFpbhgFIAEoCUIZukcWOhQSEnN0YXR1cy5leGFtcGxlLmNvbRIRCglwdWJsaXNoZWQYBiABKAgSPgoLYWNjZXNzX3R5cGUYByABKA4yKS5vcGVuc3RhdHVzLnN0YXR1c19wYWdlLnYxLlBhZ2VBY2Nlc3NUeXBlEjMKBXRoZW1lGAggASgOMiQub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5QYWdlVGhlbWUSFAoMaG9tZXBhZ2VfdXJsGAkgASgJEhMKC2NvbnRhY3RfdXJsGAogASgJEgwKBGljb24YCyABKAkSLwoKY3JlYXRlZF9hdBgMIAEoCUIbukcYOhYSFDIwMjQtMDEtMTVUMDk6MDA6MDBaEi8KCnVwZGF0ZWRfYXQYDSABKAlCG7pHGDoWEhQyMDI0LTA2LTIwVDE0OjMwOjAwWiJ3ChFTdGF0dXNQYWdlU3VtbWFyeRIKCgJpZBgBIAEoCRINCgV0aXRsZRgCIAEoCRIMCgRzbHVnGAMgASgJEhEKCXB1Ymxpc2hlZBgEIAEoCBISCgpjcmVhdGVkX2F0GAUgASgJEhIKCnVwZGF0ZWRfYXQYBiABKAkqnAEKDlBhZ2VBY2Nlc3NUeXBlEiAKHFBBR0VfQUNDRVNTX1RZUEVfVU5TUEVDSUZJRUQQABIbChdQQUdFX0FDQ0VTU19UWVBFX1BVQkxJQxABEicKI1BBR0VfQUNDRVNTX1RZUEVfUEFTU1dPUkRfUFJPVEVDVEVEEAISIgoeUEFHRV9BQ0NFU1NfVFlQRV9BVVRIRU5USUNBVEVEEAMqaQoJUGFnZVRoZW1lEhoKFlBBR0VfVEhFTUVfVU5TUEVDSUZJRUQQABIVChFQQUdFX1RIRU1FX1NZU1RFTRABEhQKEFBBR0VfVEhFTUVfTElHSFQQAhITCg9QQUdFX1RIRU1FX0RBUksQAyrsAQoNT3ZlcmFsbFN0YXR1cxIeChpPVkVSQUxMX1NUQVRVU19VTlNQRUNJRklFRBAAEh4KGk9WRVJBTExfU1RBVFVTX09QRVJBVElPTkFMEAESGwoXT1ZFUkFMTF9TVEFUVVNfREVHUkFERUQQAhIhCh1PVkVSQUxMX1NUQVRVU19QQVJUSUFMX09VVEFHRRADEh8KG09WRVJBTExfU1RBVFVTX01BSk9SX09VVEFHRRAEEh4KGk9WRVJBTExfU1RBVFVTX01BSU5URU5BTkNFEAUSGgoWT1ZFUkFMTF9TVEFUVVNfVU5LTk9XThAGQlpaWGdpdGh1Yi5jb20vb3BlbnN0YXR1c2hxL29wZW5zdGF0dXMvcGFja2FnZXMvcHJvdG8vb3BlbnN0YXR1cy9zdGF0dXNfcGFnZS92MTtzdGF0dXNwYWdldjFiBnByb3RvMw", [file_gnostic_openapi_v3_annotations]);
fileDesc("CitvcGVuc3RhdHVzL3N0YXR1c19wYWdlL3YxL3N0YXR1c19wYWdlLnByb3RvEhlvcGVuc3RhdHVzLnN0YXR1c19wYWdlLnYxIqAECgpTdGF0dXNQYWdlEgoKAmlkGAEgASgJEg0KBXRpdGxlGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJEh4KBHNsdWcYBCABKAlCELpHDToLEglhY21lLWNvcnASMAoNY3VzdG9tX2RvbWFpbhgFIAEoCUIZukcWOhQSEnN0YXR1cy5leGFtcGxlLmNvbRIRCglwdWJsaXNoZWQYBiABKAgSPgoLYWNjZXNzX3R5cGUYByABKA4yKS5vcGVuc3RhdHVzLnN0YXR1c19wYWdlLnYxLlBhZ2VBY2Nlc3NUeXBlEjMKBXRoZW1lGAggASgOMiQub3BlbnN0YXR1cy5zdGF0dXNfcGFnZS52MS5QYWdlVGhlbWUSFAoMaG9tZXBhZ2VfdXJsGAkgASgJEhMKC2NvbnRhY3RfdXJsGAogASgJEgwKBGljb24YCyABKAkSLwoKY3JlYXRlZF9hdBgMIAEoCUIbukcYOhYSFDIwMjQtMDEtMTVUMDk6MDA6MDBaEi8KCnVwZGF0ZWRfYXQYDSABKAlCG7pHGDoWEhQyMDI0LTA2LTIwVDE0OjMwOjAwWhI5Cg5kZWZhdWx0X2xvY2FsZRgOIAEoDjIhLm9wZW5zdGF0dXMuc3RhdHVzX3BhZ2UudjEuTG9jYWxlEjIKB2xvY2FsZXMYDyADKA4yIS5vcGVuc3RhdHVzLnN0YXR1c19wYWdlLnYxLkxvY2FsZSJ3ChFTdGF0dXNQYWdlU3VtbWFyeRIKCgJpZBgBIAEoCRINCgV0aXRsZRgCIAEoCRIMCgRzbHVnGAMgASgJEhEKCXB1Ymxpc2hlZBgEIAEoCBISCgpjcmVhdGVkX2F0GAUgASgJEhIKCnVwZGF0ZWRfYXQYBiABKAkqnAEKDlBhZ2VBY2Nlc3NUeXBlEiAKHFBBR0VfQUNDRVNTX1RZUEVfVU5TUEVDSUZJRUQQABIbChdQQUdFX0FDQ0VTU19UWVBFX1BVQkxJQxABEicKI1BBR0VfQUNDRVNTX1RZUEVfUEFTU1dPUkRfUFJPVEVDVEVEEAISIgoeUEFHRV9BQ0NFU1NfVFlQRV9BVVRIRU5USUNBVEVEEAMqaQoJUGFnZVRoZW1lEhoKFlBBR0VfVEhFTUVfVU5TUEVDSUZJRUQQABIVChFQQUdFX1RIRU1FX1NZU1RFTRABEhQKEFBBR0VfVEhFTUVfTElHSFQQAhITCg9QQUdFX1RIRU1FX0RBUksQAypNCgZMb2NhbGUSFgoSTE9DQUxFX1VOU1BFQ0lGSUVEEAASDQoJTE9DQUxFX0VOEAESDQoJTE9DQUxFX0ZSEAISDQoJTE9DQUxFX0RFEAMq7AEKDU92ZXJhbGxTdGF0dXMSHgoaT1ZFUkFMTF9TVEFUVVNfVU5TUEVDSUZJRUQQABIeChpPVkVSQUxMX1NUQVRVU19PUEVSQVRJT05BTBABEhsKF09WRVJBTExfU1RBVFVTX0RFR1JBREVEEAISIQodT1ZFUkFMTF9TVEFUVVNfUEFSVElBTF9PVVRBR0UQAxIfChtPVkVSQUxMX1NUQVRVU19NQUpPUl9PVVRBR0UQBBIeChpPVkVSQUxMX1NUQVRVU19NQUlOVEVOQU5DRRAFEhoKFk9WRVJBTExfU1RBVFVTX1VOS05PV04QBkJaWlhnaXRodWIuY29tL29wZW5zdGF0dXNocS9vcGVuc3RhdHVzL3BhY2thZ2VzL3Byb3RvL29wZW5zdGF0dXMvc3RhdHVzX3BhZ2UvdjE7c3RhdHVzcGFnZXYxYgZwcm90bzM", [file_gnostic_openapi_v3_annotations]);

/**
* StatusPage represents a full status page with all details.
Expand Down Expand Up @@ -109,6 +109,20 @@ export type StatusPage = Message<"openstatus.status_page.v1.StatusPage"> & {
* @generated from field: string updated_at = 13;
*/
updatedAt: string;

/**
* Default locale for the status page.
*
* @generated from field: openstatus.status_page.v1.Locale default_locale = 14;
*/
defaultLocale: Locale;

/**
* Enabled locales for the status page.
*
* @generated from field: repeated openstatus.status_page.v1.Locale locales = 15;
*/
locales: Locale[];
};

/**
Expand Down Expand Up @@ -240,6 +254,39 @@ export enum PageTheme {
export const PageThemeSchema: GenEnum<PageTheme> = /*@__PURE__*/
enumDesc(file_openstatus_status_page_v1_status_page, 1);

/**
* Locale defines the supported languages for a status page.
*
* @generated from enum openstatus.status_page.v1.Locale
*/
export enum Locale {
/**
* @generated from enum value: LOCALE_UNSPECIFIED = 0;
*/
UNSPECIFIED = 0,

/**
* @generated from enum value: LOCALE_EN = 1;
*/
EN = 1,

/**
* @generated from enum value: LOCALE_FR = 2;
*/
FR = 2,

/**
* @generated from enum value: LOCALE_DE = 3;
*/
DE = 3,
}

/**
* Describes the enum openstatus.status_page.v1.Locale.
*/
export const LocaleSchema: GenEnum<Locale> = /*@__PURE__*/
enumDesc(file_openstatus_status_page_v1_status_page, 2);

/**
* OverallStatus represents the aggregated status of all components on a page.
*
Expand Down Expand Up @@ -286,5 +333,5 @@ export enum OverallStatus {
* Describes the enum openstatus.status_page.v1.OverallStatus.
*/
export const OverallStatusSchema: GenEnum<OverallStatus> = /*@__PURE__*/
enumDesc(file_openstatus_status_page_v1_status_page, 2);
enumDesc(file_openstatus_status_page_v1_status_page, 3);

Loading