From 514f907419e7d88ad8c28d0284b4856bf046b1e3 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Mon, 27 Jul 2026 08:58:03 +0000 Subject: [PATCH] api improvment (#2448) * api improvment * ci: apply automated fixes * pr fix * fix ci --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .github/workflows/buf-push.yml | 49 +++++ .github/workflows/proto-check.yml | 4 + .../monitor/__tests__/monitor.test.ts | 176 ++++++++++++++++ .../src/routes/rpc/handlers/monitor/index.ts | 41 ++-- .../src/routes/rpc/handlers/monitor/limits.ts | 47 +++-- .../routes/rpc/handlers/monitor/validators.ts | 2 + .../status-page/__tests__/status-page.test.ts | 191 +++++++++++++++++- .../routes/rpc/handlers/status-page/index.ts | 62 +++--- .../__tests__/status-report.test.ts | 65 ++++++ .../rpc/handlers/status-report/index.ts | 11 +- apps/server/static/openapi.yaml | 36 +++- .../openstatus/monitor/v1/dns_monitor.proto | 6 +- .../openstatus/monitor/v1/http_monitor.proto | 6 +- .../openstatus/monitor/v1/tcp_monitor.proto | 6 +- packages/proto/gen/openapi.yaml | 36 +++- .../openstatus/monitor/v1/dns_monitor_pb.ts | 14 +- .../openstatus/monitor/v1/http_monitor_pb.ts | 14 +- .../openstatus/monitor/v1/tcp_monitor_pb.ts | 14 +- packages/proto/package.json | 1 + 19 files changed, 657 insertions(+), 124 deletions(-) create mode 100644 .github/workflows/buf-push.yml diff --git a/.github/workflows/buf-push.yml b/.github/workflows/buf-push.yml new file mode 100644 index 00000000..12ff5ab5 --- /dev/null +++ b/.github/workflows/buf-push.yml @@ -0,0 +1,49 @@ +name: Push API Schema to BSR +on: + push: + branches: + - "main" + paths: + - "packages/proto/api/**" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: buf-push + cancel-in-progress: false + +jobs: + buf-push: + name: ๐Ÿš€ Push api module to the BSR + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@v6 + with: + # --git-metadata derives labels from the tags/branches pointing at HEAD + fetch-depth: 0 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + + - name: โŽ” Setup node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: "pnpm" + + - name: ๐Ÿ“ฅ Download deps + run: pnpm install + + - name: ๐Ÿงน Lint api protos + working-directory: ./packages/proto + run: pnpm buf:lint:api + + - name: ๐Ÿš€ Push to BSR + working-directory: ./packages/proto + env: + BUF_TOKEN: ${{ secrets.BUF_TOKEN }} + run: pnpm exec buf push api --git-metadata diff --git a/.github/workflows/proto-check.yml b/.github/workflows/proto-check.yml index 12988d98..198e9090 100644 --- a/.github/workflows/proto-check.yml +++ b/.github/workflows/proto-check.yml @@ -47,6 +47,10 @@ jobs: - name: ๐Ÿ“ฅ Download deps run: pnpm install + - name: ๐Ÿงน Lint api protos + working-directory: ./packages/proto + run: pnpm buf:lint:api + - name: ๐Ÿ”„ Regenerate proto files working-directory: ./packages/proto run: | diff --git a/apps/server/src/routes/rpc/handlers/monitor/__tests__/monitor.test.ts b/apps/server/src/routes/rpc/handlers/monitor/__tests__/monitor.test.ts index 8b5b8e46..494bf93f 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/__tests__/monitor.test.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/__tests__/monitor.test.ts @@ -979,6 +979,88 @@ describe("MonitorService.UpdateHTTPMonitor", () => { expect(res.status).toBe(401); }); + + test("partial update preserves active, public and description", async () => { + const mon = await db + .insert(monitor) + .values({ + workspaceId: 1, + name: `${TEST_PREFIX}-preserve`, + url: "https://preserve.example.com", + periodicity: "1m", + active: true, + public: true, + description: "keep me", + regions: "ams", + jobType: "http", + }) + .returning() + .get(); + + try { + const res = await connectRequest( + "UpdateHTTPMonitor", + { + id: String(mon.id), + monitor: { name: `${TEST_PREFIX}-preserve-renamed` }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.monitor.name).toBe(`${TEST_PREFIX}-preserve-renamed`); + expect(data.monitor.active).toBe(true); + expect(data.monitor.public).toBe(true); + expect(data.monitor.description).toBe("keep me"); + } finally { + await db.delete(monitor).where(eq(monitor.id, mon.id)); + } + }); + + test("update applies explicit false for active and public", async () => { + const mon = await db + .insert(monitor) + .values({ + workspaceId: 1, + name: `${TEST_PREFIX}-explicit-false`, + url: "https://explicit-false.example.com", + periodicity: "1m", + active: true, + public: true, + description: "clear me", + regions: "ams", + jobType: "http", + }) + .returning() + .get(); + + try { + const res = await connectRequest( + "UpdateHTTPMonitor", + { + id: String(mon.id), + monitor: { active: false, public: false, description: "" }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + const updated = await db + .select() + .from(monitor) + .where(eq(monitor.id, mon.id)) + .get(); + + expect(updated?.active).toBe(false); + expect(updated?.public).toBe(false); + expect(updated?.description).toBe(""); + } finally { + await db.delete(monitor).where(eq(monitor.id, mon.id)); + } + }); }); describe("MonitorService.UpdateTCPMonitor", () => { @@ -1779,6 +1861,100 @@ describe("MonitorService - Limits", () => { const data = await res.json(); expect(data.message).toContain("periodicity"); }); + + // The free plan allows a single monitor, so one row puts workspace 2 at its + // cap โ€” which is the normal state for any workspace on its plan limit. + async function seedFreePlanMonitorAtCap(suffix: string) { + return db + .insert(monitor) + .values({ + workspaceId: 2, + name: `${TEST_PREFIX}-at-cap-${suffix}`, + url: `https://at-cap-${suffix}.example.com`, + periodicity: "10m", + active: true, + regions: "ams", + jobType: "http", + }) + .returning() + .get(); + } + + // Regression: the row-count cap is a create-time check. A workspace sitting + // at its monitor limit must still be able to edit the monitors it has. + test("workspace at its monitor cap can still update an existing monitor", async () => { + const mon = await seedFreePlanMonitorAtCap("update"); + + try { + const res = await connectRequest( + "UpdateHTTPMonitor", + { + id: String(mon.id), + monitor: { periodicity: "PERIODICITY_30M" }, + }, + { "x-openstatus-key": FREE_PLAN_KEY }, + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.monitor.periodicity).toBe("PERIODICITY_30M"); + } finally { + await db.delete(monitor).where(eq(monitor.id, mon.id)); + } + }); + + test("update still enforces the plan's periodicity limit", async () => { + const mon = await seedFreePlanMonitorAtCap("periodicity"); + + try { + const res = await connectRequest( + "UpdateHTTPMonitor", + { + id: String(mon.id), + monitor: { periodicity: "PERIODICITY_30S" }, + }, + { "x-openstatus-key": FREE_PLAN_KEY }, + ); + + expect(res.status).toBe(403); + const data = await res.json(); + expect(data.message).toContain("periodicity"); + } finally { + await db.delete(monitor).where(eq(monitor.id, mon.id)); + } + }); + + test("update still enforces the plan's max-regions limit", async () => { + const mon = await seedFreePlanMonitorAtCap("regions"); + + try { + const res = await connectRequest( + "UpdateHTTPMonitor", + { + id: String(mon.id), + monitor: { + regions: [ + "REGION_FLY_AMS", + "REGION_FLY_IAD", + "REGION_FLY_SIN", + "REGION_FLY_LHR", + "REGION_FLY_SYD", + "REGION_FLY_NRT", + "REGION_FLY_FRA", + "REGION_FLY_GRU", + ], + }, + }, + { "x-openstatus-key": FREE_PLAN_KEY }, + ); + + expect(res.status).toBe(403); + const data = await res.json(); + expect(data.message).toContain("region"); + } finally { + await db.delete(monitor).where(eq(monitor.id, mon.id)); + } + }); }); describe("MonitorService - Status Field", () => { diff --git a/apps/server/src/routes/rpc/handlers/monitor/index.ts b/apps/server/src/routes/rpc/handlers/monitor/index.ts index 885e3057..fa345834 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/index.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/index.ts @@ -68,7 +68,7 @@ import { responseLogNotFoundError, responseLogsNotEnabledError, } from "./errors"; -import { checkMonitorLimits } from "./limits"; +import { checkMonitorConfigLimits, checkMonitorLimits } from "./limits"; import { toHTTPResponseLogDetail, toHTTPResponseLogListItem, @@ -347,14 +347,11 @@ export const monitorServiceImpl: ServiceImpl = { validateCommonMonitorFields(mon); // Check workspace limits if periodicity or regions are changing - if (mon.periodicity || (mon.regions && mon.regions.length > 0)) { - await checkMonitorLimits( - workspaceId, - limits, - mon.periodicity || undefined, - mon.regions && mon.regions.length > 0 ? mon.regions : undefined, - ); - } + checkMonitorConfigLimits( + limits, + mon.periodicity || undefined, + mon.regions && mon.regions.length > 0 ? mon.regions : undefined, + ); // Build update values - only include fields that are provided const updateValues: Record = @@ -429,14 +426,11 @@ export const monitorServiceImpl: ServiceImpl = { validateCommonMonitorFields(mon); // Check workspace limits if periodicity or regions are changing - if (mon.periodicity || (mon.regions && mon.regions.length > 0)) { - await checkMonitorLimits( - workspaceId, - limits, - mon.periodicity || undefined, - mon.regions && mon.regions.length > 0 ? mon.regions : undefined, - ); - } + checkMonitorConfigLimits( + limits, + mon.periodicity || undefined, + mon.regions && mon.regions.length > 0 ? mon.regions : undefined, + ); // Build update values - only include fields that are provided const updateValues: Record = @@ -482,14 +476,11 @@ export const monitorServiceImpl: ServiceImpl = { validateCommonMonitorFields(mon); // Check workspace limits if periodicity or regions are changing - if (mon.periodicity || (mon.regions && mon.regions.length > 0)) { - await checkMonitorLimits( - workspaceId, - limits, - mon.periodicity || undefined, - mon.regions && mon.regions.length > 0 ? mon.regions : undefined, - ); - } + checkMonitorConfigLimits( + limits, + mon.periodicity || undefined, + mon.regions && mon.regions.length > 0 ? mon.regions : undefined, + ); // Build update values - only include fields that are provided const updateValues: Record = diff --git a/apps/server/src/routes/rpc/handlers/monitor/limits.ts b/apps/server/src/routes/rpc/handlers/monitor/limits.ts index 3a90bcf9..3ab91d52 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/limits.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/limits.ts @@ -9,27 +9,15 @@ import { z } from "zod"; import { periodicityToString, regionsToStrings } from "./converters"; /** - * Check workspace limits for creating a new monitor. + * Check the plan limits that apply to a monitor's configuration. Safe on both + * create and update โ€” it never looks at how many monitors already exist. * Throws ConnectError with PermissionDenied if any limit is exceeded. */ -export async function checkMonitorLimits( - workspaceId: number, +export function checkMonitorConfigLimits( limits: Limits, periodicity: Periodicity | undefined, regions: Region[] | undefined, -): Promise { - // Check monitor count limit - const countResult = await db - .select({ count: sql`count(*)` }) - .from(monitor) - .where(and(eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt))) - .get(); - - const count = countResult?.count ?? 0; - if (count >= limits.monitors) { - throw new ConnectError("Upgrade for more monitors", Code.PermissionDenied); - } - +): void { // Check periodicity limit if (periodicity) { const periodicityStr = periodicityToString(periodicity); @@ -63,3 +51,30 @@ export async function checkMonitorLimits( } } } + +/** + * Check workspace limits for creating a new monitor. + * Throws ConnectError with PermissionDenied if any limit is exceeded. + * + * Create-only: the row-count cap must not run on update, or a workspace + * sitting at its limit could no longer edit the monitors it already has. + */ +export async function checkMonitorLimits( + workspaceId: number, + limits: Limits, + periodicity: Periodicity | undefined, + regions: Region[] | undefined, +): Promise { + const countResult = await db + .select({ count: sql`count(*)` }) + .from(monitor) + .where(and(eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt))) + .get(); + + const count = countResult?.count ?? 0; + if (count >= limits.monitors) { + throw new ConnectError("Upgrade for more monitors", Code.PermissionDenied); + } + + checkMonitorConfigLimits(limits, periodicity, regions); +} diff --git a/apps/server/src/routes/rpc/handlers/monitor/validators.ts b/apps/server/src/routes/rpc/handlers/monitor/validators.ts index d01b008e..0d0e3ff9 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/validators.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/validators.ts @@ -131,6 +131,8 @@ export function getCommonDbValuesForUpdate(mon: { result.degradedAfter = Number(mon.degradedAt); } + // `active`, `public` and `description` have explicit presence in the proto, + // so `undefined` means omitted and an explicit false/"" is applied. if (mon.active !== undefined) { result.active = mon.active; } 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 2e07ced7..20799f04 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 @@ -778,14 +778,15 @@ describe("StatusPageService.UpdateStatusPage", () => { .where(eq(page.id, testPageToUpdateId)); }); - test("clears locales when field is omitted", async () => { - // Set some locales + // `locales` is `repeated`, so an omitted field is indistinguishable from an + // empty one โ€” both have to mean "keep", or every partial update would drop + // the page's languages. + test("keeps locales when field is omitted", async () => { await db .update(page) .set({ defaultLocale: "en", locales: ["en", "fr"] }) .where(eq(page.id, testPageToUpdateId)); - // Omitting locales clears them (same as sending []) const res = await connectRequest( "UpdateStatusPage", { @@ -798,7 +799,8 @@ describe("StatusPageService.UpdateStatusPage", () => { expect(res.status).toBe(200); const data = await res.json(); - expect(data.statusPage.locales ?? []).toEqual([]); + expect(data.statusPage.locales).toEqual(["LOCALE_EN", "LOCALE_FR"]); + expect(data.statusPage.defaultLocale).toBe("LOCALE_EN"); // Restore defaults await db @@ -811,14 +813,12 @@ describe("StatusPageService.UpdateStatusPage", () => { .where(eq(page.id, testPageToUpdateId)); }); - test("resets locales to null when empty list is sent", async () => { - // First set some locales + test("keeps locales when an empty list is sent", async () => { await db .update(page) .set({ defaultLocale: "en", locales: ["en", "fr"] }) .where(eq(page.id, testPageToUpdateId)); - // Send empty locales to clear them const res = await connectRequest( "UpdateStatusPage", { @@ -831,7 +831,65 @@ describe("StatusPageService.UpdateStatusPage", () => { expect(res.status).toBe(200); const data = await res.json(); - expect(data.statusPage.locales ?? []).toEqual([]); + expect(data.statusPage.locales).toEqual(["LOCALE_EN", "LOCALE_FR"]); + + // Restore defaults + await db + .update(page) + .set({ defaultLocale: "en", locales: null }) + .where(eq(page.id, testPageToUpdateId)); + }); + + test("keeps the default locale when LOCALE_UNSPECIFIED is sent", async () => { + await db + .update(page) + .set({ defaultLocale: "fr", locales: ["en", "fr"] }) + .where(eq(page.id, testPageToUpdateId)); + + const res = await connectRequest( + "UpdateStatusPage", + { + id: String(testPageToUpdateId), + defaultLocale: "LOCALE_UNSPECIFIED", + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + // Must not silently fall back to "en" + 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, testPageToUpdateId)); + }); + + test("still replaces locales when a non-empty list is sent", async () => { + await db + .update(page) + .set({ defaultLocale: "en", locales: ["en", "fr"] }) + .where(eq(page.id, testPageToUpdateId)); + + const res = await connectRequest( + "UpdateStatusPage", + { + id: String(testPageToUpdateId), + defaultLocale: "LOCALE_DE", + locales: ["LOCALE_DE", "LOCALE_EN"], + }, + { "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_DE", "LOCALE_EN"]); // Restore defaults await db @@ -1869,6 +1927,123 @@ describe("StatusPageService.UpdateComponent", () => { }); }); +// A group belongs to exactly one page. Workspace scope alone would let a +// component be filed under a group from a sibling page. +describe("StatusPageService โ€” component group must be on the same page", () => { + let otherPageId: number; + let otherGroupId: number; + + beforeAll(async () => { + await db + .delete(pageComponentGroup) + .where(eq(pageComponentGroup.name, `${TEST_PREFIX}-other-group`)); + await db + .delete(page) + .where(eq(page.slug, `${TEST_PREFIX}-other-page-slug`)); + + const otherPage = await db + .insert(page) + .values({ + workspaceId: 1, + title: `${TEST_PREFIX}-other-page`, + slug: `${TEST_PREFIX}-other-page-slug`, + description: "Second page, owns a group of its own", + customDomain: "", + }) + .returning() + .get(); + otherPageId = otherPage.id; + + const otherGroup = await db + .insert(pageComponentGroup) + .values({ + workspaceId: 1, + pageId: otherPageId, + name: `${TEST_PREFIX}-other-group`, + }) + .returning() + .get(); + otherGroupId = otherGroup.id; + }); + + afterAll(async () => { + await db + .delete(pageComponentGroup) + .where(eq(pageComponentGroup.id, otherGroupId)); + await db.delete(page).where(eq(page.id, otherPageId)); + }); + + test("AddMonitorComponent rejects a group from another page", async () => { + const res = await connectRequest( + "AddMonitorComponent", + { + pageId: String(testPageId), + monitorId: String(testMonitorId), + name: `${TEST_PREFIX}-cross-page-monitor`, + groupId: String(otherGroupId), + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(404); + }); + + test("AddStaticComponent rejects a group from another page", async () => { + const res = await connectRequest( + "AddStaticComponent", + { + pageId: String(testPageId), + name: `${TEST_PREFIX}-cross-page-static`, + groupId: String(otherGroupId), + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(404); + }); + + test("UpdateComponent rejects a group from another page", async () => { + const res = await connectRequest( + "UpdateComponent", + { + id: String(testComponentToUpdateId), + groupId: String(otherGroupId), + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(404); + + const stored = await db + .select() + .from(pageComponent) + .where(eq(pageComponent.id, testComponentToUpdateId)) + .get(); + expect(stored?.groupId ?? null).toBe(null); + }); + + test("a group on the same page is still accepted", async () => { + const res = await connectRequest( + "AddStaticComponent", + { + pageId: String(testPageId), + name: `${TEST_PREFIX}-same-page-static`, + groupId: String(testGroupId), + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.component.groupId).toBe(String(testGroupId)); + + await db + .delete(pageComponent) + .where(eq(pageComponent.id, Number(data.component.id))); + }); +}); + // ========================================================================== // Component Groups // ========================================================================== 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 f83ae4e3..198e1eaa 100644 --- a/apps/server/src/routes/rpc/handlers/status-page/index.ts +++ b/apps/server/src/routes/rpc/handlers/status-page/index.ts @@ -300,6 +300,24 @@ async function getGroupById(id: number, workspaceId: number) { .get(); } +/** + * Resolve a component group and assert it belongs to `pageId`. Workspace scope + * alone isn't enough โ€” a group from a sibling page would otherwise be accepted + * and the component would render under a group it isn't on. Reported as + * not-found so the check doesn't confirm the group exists on another page. + */ +async function getGroupForPage( + groupId: string, + workspaceId: number, + pageId: number, +) { + const group = await getGroupById(Number(groupId), workspaceId); + if (!group || group.pageId !== pageId) { + throw componentGroupNotFoundError(groupId); + } + return group; +} + /** * Helper to get a monitor by ID with workspace scope. */ @@ -786,30 +804,35 @@ export const statusPageServiceImpl: ServiceImpl = { } if (req.icon !== undefined && req.icon) validateIconUrl(req.icon); - // Locale merge + cross-field validation. + // Locale merge + cross-field validation. `default_locale` is an enum and + // `locales` is `repeated`, so an omitted field decodes to UNSPECIFIED / + // `[]` โ€” treat both as "not provided" and keep what is stored, or + // updating a title would silently reset the page's languages. Same + // presence test the theme / access-type fields below use. Trade-off: + // locales can't be cleared over this RPC until the proto carries + // explicit presence. + const reqDefaultLocale = + req.defaultLocale !== undefined && req.defaultLocale !== 0 + ? req.defaultLocale + : undefined; const nextDefaultLocale = - req.defaultLocale !== undefined - ? protoLocaleToDb(req.defaultLocale) + reqDefaultLocale !== undefined + ? protoLocaleToDb(reqDefaultLocale) : existing.defaultLocale; const validLocales = req.locales.filter((l) => l !== 0); const nextLocales = validLocales.length > 0 ? [...new Set(validLocales.map(protoLocaleToDb))] - : null; + : existing.locales; if (nextLocales && !nextLocales.includes(nextDefaultLocale)) { throw new ConnectError( "Default locale must be included in the locales list", Code.InvalidArgument, ); } - // `UpdateStatusPage` syncs locales on every call when the - // workspace has i18n โ€” proto can't distinguish "field omitted" - // from "field = []", so the wire contract is "empty locales - // means clear". Gating on `req.locales.length > 0` meant omit - // and empty both became no-ops, leaving stale locales on the - // page. Skip the call only on plans without i18n, where the - // service would throw `LimitExceededError` regardless. - const localesChanged = limits.i18n === true; + const localesChanged = + limits.i18n === true && + (reqDefaultLocale !== undefined || validLocales.length > 0); const generalChanged = (req.title !== undefined && req.title !== "") || @@ -1024,10 +1047,7 @@ export const statusPageServiceImpl: ServiceImpl = { // Validate group exists if provided if (req.groupId) { - const group = await getGroupById(Number(req.groupId), workspaceId); - if (!group) { - throw componentGroupNotFoundError(req.groupId); - } + await getGroupForPage(req.groupId, workspaceId, pageData.id); } // Create the component @@ -1071,10 +1091,7 @@ export const statusPageServiceImpl: ServiceImpl = { // Validate group exists if provided if (req.groupId) { - const group = await getGroupById(Number(req.groupId), workspaceId); - if (!group) { - throw componentGroupNotFoundError(req.groupId); - } + await getGroupForPage(req.groupId, workspaceId, pageData.id); } // Create the component @@ -1139,10 +1156,7 @@ export const statusPageServiceImpl: ServiceImpl = { // Validate group exists if provided if (req.groupId !== undefined && req.groupId !== "") { - const group = await getGroupById(Number(req.groupId), workspaceId); - if (!group) { - throw componentGroupNotFoundError(req.groupId); - } + await getGroupForPage(req.groupId, workspaceId, component.pageId); } // Build update values diff --git a/apps/server/src/routes/rpc/handlers/status-report/__tests__/status-report.test.ts b/apps/server/src/routes/rpc/handlers/status-report/__tests__/status-report.test.ts index 03f4c58a..8e7477f4 100644 --- a/apps/server/src/routes/rpc/handlers/status-report/__tests__/status-report.test.ts +++ b/apps/server/src/routes/rpc/handlers/status-report/__tests__/status-report.test.ts @@ -366,6 +366,55 @@ describe("StatusReportService.CreateStatusReport", () => { expect(res.status).toBe(404); }); + // `Number("")` is 0 and `Number.parseInt("1.5")` is 1, so a malformed id used + // to be coerced into a real component id rather than rejected โ€” an empty + // string silently targeted component 0. + test("rejects malformed page component ids", async () => { + for (const bad of ["", " ", "1.5", "1e3", "-1", "abc", "1abc"]) { + const res = await connectRequest( + "CreateStatusReport", + { + title: `${TEST_PREFIX}-bad-component-id`, + status: "STATUS_REPORT_STATUS_INVESTIGATING", + message: "Test message", + date: new Date().toISOString(), + pageId: "1", + pageComponentIds: [bad], + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.message).toContain("Invalid page component id"); + } + }); + + test("rejects a malformed component impact id", async () => { + const res = await connectRequest( + "CreateStatusReport", + { + title: `${TEST_PREFIX}-bad-impact-id`, + status: "STATUS_REPORT_STATUS_INVESTIGATING", + message: "Test message", + date: new Date().toISOString(), + pageId: "1", + pageComponentIds: [String(testPageComponentId)], + componentImpacts: [ + { + pageComponentId: "", + impact: "PAGE_COMPONENT_IMPACT_MAJOR_OUTAGE", + }, + ], + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.message).toContain("Invalid page component id"); + }); + test("returns error when page components are from different pages", async () => { const res = await connectRequest( "CreateStatusReport", @@ -1104,6 +1153,22 @@ describe("StatusReportService.UpdateStatusReport", () => { expect(res.status).toBe(404); }); + test("rejects a malformed page component id on update", async () => { + const res = await connectRequest( + "UpdateStatusReport", + { + id: String(testStatusReportToUpdateId), + pageComponentIds: [""], + updatePageComponentIds: true, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.message).toContain("Invalid page component id"); + }); + test("returns error when updating with components from different pages", async () => { const res = await connectRequest( "UpdateStatusReport", diff --git a/apps/server/src/routes/rpc/handlers/status-report/index.ts b/apps/server/src/routes/rpc/handlers/status-report/index.ts index 66b0553b..e50d07dd 100644 --- a/apps/server/src/routes/rpc/handlers/status-report/index.ts +++ b/apps/server/src/routes/rpc/handlers/status-report/index.ts @@ -32,16 +32,21 @@ function parseDate(dateString: string): Date { return date; } +// Match the digits explicitly: `Number("")` is 0 (finite!), so a blank id used +// to slip through and target component 0, and `Number.parseInt("1.5")` is 1, so +// swapping in parseInt alone would still truncate a malformed id silently. +const PAGE_COMPONENT_ID = /^\d+$/; + function parsePageComponentIds(ids: ReadonlyArray): number[] { return ids.map((id) => { - const n = Number(id); - if (!Number.isFinite(n)) { + const trimmed = id.trim(); + if (!PAGE_COMPONENT_ID.test(trimmed)) { throw new ConnectError( `Invalid page component id: "${id}"`, Code.InvalidArgument, ); } - return n; + return Number(trimmed); }); } diff --git a/apps/server/static/openapi.yaml b/apps/server/static/openapi.yaml index d482b46e..507f9fae 100644 --- a/apps/server/static/openapi.yaml +++ b/apps/server/static/openapi.yaml @@ -558,16 +558,22 @@ components: maxItems: 10 description: DNS record assertions for validation. description: - type: string + type: + - string + - "null" title: description maxLength: 1024 description: Description of the monitor (optional). active: - type: boolean + type: + - boolean + - "null" title: active description: Whether the monitor is active (defaults to false). public: - type: boolean + type: + - boolean + - "null" title: public description: Whether the monitor is publicly visible (defaults to false). regions: @@ -915,16 +921,22 @@ components: maxItems: 10 description: Header assertions for the response. description: - type: string + type: + - string + - "null" title: description maxLength: 1024 description: Description of the monitor (optional). active: - type: boolean + type: + - boolean + - "null" title: active description: Whether the monitor is active (defaults to false). public: - type: boolean + type: + - boolean + - "null" title: public description: Whether the monitor is publicly visible (defaults to false). regions: @@ -1555,16 +1567,22 @@ components: format: int64 description: Number of retry attempts (0-10, defaults to 3). description: - type: string + type: + - string + - "null" title: description maxLength: 1024 description: Description of the monitor (optional). active: - type: boolean + type: + - boolean + - "null" title: active description: Whether the monitor is active (defaults to false). public: - type: boolean + type: + - boolean + - "null" title: public description: Whether the monitor is publicly visible (defaults to false). regions: diff --git a/packages/proto/api/openstatus/monitor/v1/dns_monitor.proto b/packages/proto/api/openstatus/monitor/v1/dns_monitor.proto index 9d5dc52d..ad94cffb 100644 --- a/packages/proto/api/openstatus/monitor/v1/dns_monitor.proto +++ b/packages/proto/api/openstatus/monitor/v1/dns_monitor.proto @@ -60,13 +60,13 @@ message DNSMonitor { repeated RecordAssertion record_assertions = 8 [(buf.validate.field).repeated.max_items = 10]; // Description of the monitor (optional). - string description = 9 [(buf.validate.field).string.max_len = 1024]; + optional string description = 9 [(buf.validate.field).string.max_len = 1024]; // Whether the monitor is active (defaults to false). - bool active = 10; + optional bool active = 10; // Whether the monitor is publicly visible (defaults to false). - bool public = 11; + optional bool public = 11; // Geographic regions to run checks from. repeated Region regions = 12 [(buf.validate.field).repeated = { diff --git a/packages/proto/api/openstatus/monitor/v1/http_monitor.proto b/packages/proto/api/openstatus/monitor/v1/http_monitor.proto index 62eb0c10..49a29d49 100644 --- a/packages/proto/api/openstatus/monitor/v1/http_monitor.proto +++ b/packages/proto/api/openstatus/monitor/v1/http_monitor.proto @@ -118,13 +118,13 @@ message HTTPMonitor { repeated HeaderAssertion header_assertions = 14 [(buf.validate.field).repeated.max_items = 10]; // Description of the monitor (optional). - string description = 15 [(buf.validate.field).string.max_len = 1024]; + optional string description = 15 [(buf.validate.field).string.max_len = 1024]; // Whether the monitor is active (defaults to false). - bool active = 16; + optional bool active = 16; // Whether the monitor is publicly visible (defaults to false). - bool public = 17; + optional bool public = 17; // Geographic regions to run checks from. repeated Region regions = 18 [(buf.validate.field).repeated = { diff --git a/packages/proto/api/openstatus/monitor/v1/tcp_monitor.proto b/packages/proto/api/openstatus/monitor/v1/tcp_monitor.proto index 43542756..0ef71475 100644 --- a/packages/proto/api/openstatus/monitor/v1/tcp_monitor.proto +++ b/packages/proto/api/openstatus/monitor/v1/tcp_monitor.proto @@ -56,13 +56,13 @@ message TCPMonitor { }]; // Description of the monitor (optional). - string description = 8 [(buf.validate.field).string.max_len = 1024]; + optional string description = 8 [(buf.validate.field).string.max_len = 1024]; // Whether the monitor is active (defaults to false). - bool active = 9; + optional bool active = 9; // Whether the monitor is publicly visible (defaults to false). - bool public = 10; + optional bool public = 10; // Geographic regions to run checks from. repeated Region regions = 11 [(buf.validate.field).repeated = { diff --git a/packages/proto/gen/openapi.yaml b/packages/proto/gen/openapi.yaml index d482b46e..507f9fae 100644 --- a/packages/proto/gen/openapi.yaml +++ b/packages/proto/gen/openapi.yaml @@ -558,16 +558,22 @@ components: maxItems: 10 description: DNS record assertions for validation. description: - type: string + type: + - string + - "null" title: description maxLength: 1024 description: Description of the monitor (optional). active: - type: boolean + type: + - boolean + - "null" title: active description: Whether the monitor is active (defaults to false). public: - type: boolean + type: + - boolean + - "null" title: public description: Whether the monitor is publicly visible (defaults to false). regions: @@ -915,16 +921,22 @@ components: maxItems: 10 description: Header assertions for the response. description: - type: string + type: + - string + - "null" title: description maxLength: 1024 description: Description of the monitor (optional). active: - type: boolean + type: + - boolean + - "null" title: active description: Whether the monitor is active (defaults to false). public: - type: boolean + type: + - boolean + - "null" title: public description: Whether the monitor is publicly visible (defaults to false). regions: @@ -1555,16 +1567,22 @@ components: format: int64 description: Number of retry attempts (0-10, defaults to 3). description: - type: string + type: + - string + - "null" title: description maxLength: 1024 description: Description of the monitor (optional). active: - type: boolean + type: + - boolean + - "null" title: active description: Whether the monitor is active (defaults to false). public: - type: boolean + type: + - boolean + - "null" title: public description: Whether the monitor is publicly visible (defaults to false). regions: diff --git a/packages/proto/gen/ts/openstatus/monitor/v1/dns_monitor_pb.ts b/packages/proto/gen/ts/openstatus/monitor/v1/dns_monitor_pb.ts index edb8e159..b7ca4aa3 100644 --- a/packages/proto/gen/ts/openstatus/monitor/v1/dns_monitor_pb.ts +++ b/packages/proto/gen/ts/openstatus/monitor/v1/dns_monitor_pb.ts @@ -18,7 +18,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file openstatus/monitor/v1/dns_monitor.proto. */ export const file_openstatus_monitor_v1_dns_monitor: GenFile = /*@__PURE__*/ - fileDesc("CidvcGVuc3RhdHVzL21vbml0b3IvdjEvZG5zX21vbml0b3IucHJvdG8SFW9wZW5zdGF0dXMubW9uaXRvci52MSKWBQoKRE5TTW9uaXRvchIKCgJpZBgBIAEoCRIzCgRuYW1lGAIgASgJQiW6Rxg6FhIURE5TIFJlc29sdXRpb24gQ2hlY2u6SAdyBRABGIACEikKA3VyaRgDIAEoCUIcukcPOg0SC2V4YW1wbGUuY29tukgHcgUQARiAEBJBCgtwZXJpb2RpY2l0eRgEIAEoDjIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5QZXJpb2RpY2l0eUIIukgFggECIAASHAoHdGltZW91dBgFIAEoA0ILukgIIgYYwKkHKAASJQoLZGVncmFkZWRfYXQYBiABKANCC7pICCIGGMCpBygASACIAQESGAoFcmV0cnkYByABKANCCbpIBiIEGAooABJLChFyZWNvcmRfYXNzZXJ0aW9ucxgIIAMoCzImLm9wZW5zdGF0dXMubW9uaXRvci52MS5SZWNvcmRBc3NlcnRpb25CCLpIBZIBAhAKEh0KC2Rlc2NyaXB0aW9uGAkgASgJQgi6SAVyAxiACBIOCgZhY3RpdmUYCiABKAgSDgoGcHVibGljGAsgASgIEj8KB3JlZ2lvbnMYDCADKA4yHS5vcGVuc3RhdHVzLm1vbml0b3IudjEuUmVnaW9uQg+6SAySAQkQHCIFggECIAASQgoOb3Blbl90ZWxlbWV0cnkYDSABKAsyKi5vcGVuc3RhdHVzLm1vbml0b3IudjEuT3BlblRlbGVtZXRyeUNvbmZpZxI0CgZzdGF0dXMYDiABKA4yJC5vcGVuc3RhdHVzLm1vbml0b3IudjEuTW9uaXRvclN0YXR1cxIjChRwcml2YXRlX2xvY2F0aW9uX2lkcxgPIAMoCUIFukcCGAFCDgoMX2RlZ3JhZGVkX2F0QlNaUWdpdGh1Yi5jb20vb3BlbnN0YXR1c2hxL29wZW5zdGF0dXMvcGFja2FnZXMvcHJvdG8vb3BlbnN0YXR1cy9tb25pdG9yL3YxO21vbml0b3J2MWIGcHJvdG8z", [file_buf_validate_validate, file_gnostic_openapi_v3_annotations, file_openstatus_monitor_v1_assertions, file_openstatus_monitor_v1_http_monitor, file_openstatus_monitor_v1_monitor]); + fileDesc("CidvcGVuc3RhdHVzL21vbml0b3IvdjEvZG5zX21vbml0b3IucHJvdG8SFW9wZW5zdGF0dXMubW9uaXRvci52MSLLBQoKRE5TTW9uaXRvchIKCgJpZBgBIAEoCRIzCgRuYW1lGAIgASgJQiW6Rxg6FhIURE5TIFJlc29sdXRpb24gQ2hlY2u6SAdyBRABGIACEikKA3VyaRgDIAEoCUIcukcPOg0SC2V4YW1wbGUuY29tukgHcgUQARiAEBJBCgtwZXJpb2RpY2l0eRgEIAEoDjIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5QZXJpb2RpY2l0eUIIukgFggECIAASHAoHdGltZW91dBgFIAEoA0ILukgIIgYYwKkHKAASJQoLZGVncmFkZWRfYXQYBiABKANCC7pICCIGGMCpBygASACIAQESGAoFcmV0cnkYByABKANCCbpIBiIEGAooABJLChFyZWNvcmRfYXNzZXJ0aW9ucxgIIAMoCzImLm9wZW5zdGF0dXMubW9uaXRvci52MS5SZWNvcmRBc3NlcnRpb25CCLpIBZIBAhAKEiIKC2Rlc2NyaXB0aW9uGAkgASgJQgi6SAVyAxiACEgBiAEBEhMKBmFjdGl2ZRgKIAEoCEgCiAEBEhMKBnB1YmxpYxgLIAEoCEgDiAEBEj8KB3JlZ2lvbnMYDCADKA4yHS5vcGVuc3RhdHVzLm1vbml0b3IudjEuUmVnaW9uQg+6SAySAQkQHCIFggECIAASQgoOb3Blbl90ZWxlbWV0cnkYDSABKAsyKi5vcGVuc3RhdHVzLm1vbml0b3IudjEuT3BlblRlbGVtZXRyeUNvbmZpZxI0CgZzdGF0dXMYDiABKA4yJC5vcGVuc3RhdHVzLm1vbml0b3IudjEuTW9uaXRvclN0YXR1cxIjChRwcml2YXRlX2xvY2F0aW9uX2lkcxgPIAMoCUIFukcCGAFCDgoMX2RlZ3JhZGVkX2F0Qg4KDF9kZXNjcmlwdGlvbkIJCgdfYWN0aXZlQgkKB19wdWJsaWNCU1pRZ2l0aHViLmNvbS9vcGVuc3RhdHVzaHEvb3BlbnN0YXR1cy9wYWNrYWdlcy9wcm90by9vcGVuc3RhdHVzL21vbml0b3IvdjE7bW9uaXRvcnYxYgZwcm90bzM", [file_buf_validate_validate, file_gnostic_openapi_v3_annotations, file_openstatus_monitor_v1_assertions, file_openstatus_monitor_v1_http_monitor, file_openstatus_monitor_v1_monitor]); /** * DNSMonitor defines the configuration for a DNS monitor. @@ -85,23 +85,23 @@ export type DNSMonitor = Message<"openstatus.monitor.v1.DNSMonitor"> & { /** * Description of the monitor (optional). * - * @generated from field: string description = 9; + * @generated from field: optional string description = 9; */ - description: string; + description?: string | undefined; /** * Whether the monitor is active (defaults to false). * - * @generated from field: bool active = 10; + * @generated from field: optional bool active = 10; */ - active: boolean; + active?: boolean | undefined; /** * Whether the monitor is publicly visible (defaults to false). * - * @generated from field: bool public = 11; + * @generated from field: optional bool public = 11; */ - public: boolean; + public?: boolean | undefined; /** * Geographic regions to run checks from. diff --git a/packages/proto/gen/ts/openstatus/monitor/v1/http_monitor_pb.ts b/packages/proto/gen/ts/openstatus/monitor/v1/http_monitor_pb.ts index 2aa29b77..7671607d 100644 --- a/packages/proto/gen/ts/openstatus/monitor/v1/http_monitor_pb.ts +++ b/packages/proto/gen/ts/openstatus/monitor/v1/http_monitor_pb.ts @@ -16,7 +16,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file openstatus/monitor/v1/http_monitor.proto. */ export const file_openstatus_monitor_v1_http_monitor: GenFile = /*@__PURE__*/ - fileDesc("CihvcGVuc3RhdHVzL21vbml0b3IvdjEvaHR0cF9tb25pdG9yLnByb3RvEhVvcGVuc3RhdHVzLm1vbml0b3IudjEiWgoHSGVhZGVycxIoCgNrZXkYASABKAlCG7pHEToPEg1BdXRob3JpemF0aW9uukgEcgIQARIlCgV2YWx1ZRgCIAEoCUIWukcTOhESD0JlYXJlciB0b2tlbjEyMyJsChNPcGVuVGVsZW1ldHJ5Q29uZmlnEhoKCGVuZHBvaW50GAEgASgJQgi6SAVyAxiAEBI5CgdoZWFkZXJzGAIgAygLMh4ub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhlYWRlcnNCCLpIBZIBAhAUIqYICgtIVFRQTW9uaXRvchIKCgJpZBgBIAEoCRI6CgRuYW1lGAIgASgJQiy6Rx86HRIbUHJvZHVjdGlvbiBBUEkgSGVhbHRoIENoZWNrukgHcgUQARiAAhI/CgN1cmwYAyABKAlCMrpHIjogEh5odHRwczovL2FwaS5leGFtcGxlLmNvbS9oZWFsdGi6SApyCBABGIAQiAEBEkEKC3BlcmlvZGljaXR5GAQgASgOMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLlBlcmlvZGljaXR5Qgi6SAWCAQIgABI7CgZtZXRob2QYBSABKA4yIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuSFRUUE1ldGhvZEIIukgFggECIAASJQoEYm9keRgGIAEoCUIXukcUOhISEHsia2V5IjogInZhbHVlIn0SHAoHdGltZW91dBgHIAEoA0ILukgIIgYYwKkHKAASJQoLZGVncmFkZWRfYXQYCCABKANCC7pICCIGGMCpBygASACIAQESGAoFcmV0cnkYCSABKANCCbpIBiIEGAooABIdChBmb2xsb3dfcmVkaXJlY3RzGAogASgISAGIAQESOQoHaGVhZGVycxgLIAMoCzIeLm9wZW5zdGF0dXMubW9uaXRvci52MS5IZWFkZXJzQgi6SAWSAQIQFBJUChZzdGF0dXNfY29kZV9hc3NlcnRpb25zGAwgAygLMioub3BlbnN0YXR1cy5tb25pdG9yLnYxLlN0YXR1c0NvZGVBc3NlcnRpb25CCLpIBZIBAhAKEkcKD2JvZHlfYXNzZXJ0aW9ucxgNIAMoCzIkLm9wZW5zdGF0dXMubW9uaXRvci52MS5Cb2R5QXNzZXJ0aW9uQgi6SAWSAQIQChJLChFoZWFkZXJfYXNzZXJ0aW9ucxgOIAMoCzImLm9wZW5zdGF0dXMubW9uaXRvci52MS5IZWFkZXJBc3NlcnRpb25CCLpIBZIBAhAKEh0KC2Rlc2NyaXB0aW9uGA8gASgJQgi6SAVyAxiACBIOCgZhY3RpdmUYECABKAgSDgoGcHVibGljGBEgASgIEj8KB3JlZ2lvbnMYEiADKA4yHS5vcGVuc3RhdHVzLm1vbml0b3IudjEuUmVnaW9uQg+6SAySAQkQHCIFggECIAASQgoOb3Blbl90ZWxlbWV0cnkYEyABKAsyKi5vcGVuc3RhdHVzLm1vbml0b3IudjEuT3BlblRlbGVtZXRyeUNvbmZpZxI0CgZzdGF0dXMYFCABKA4yJC5vcGVuc3RhdHVzLm1vbml0b3IudjEuTW9uaXRvclN0YXR1cxIjChRwcml2YXRlX2xvY2F0aW9uX2lkcxgVIAMoCUIFukcCGAFCDgoMX2RlZ3JhZGVkX2F0QhMKEV9mb2xsb3dfcmVkaXJlY3RzKvcBCgpIVFRQTWV0aG9kEhsKF0hUVFBfTUVUSE9EX1VOU1BFQ0lGSUVEEAASEwoPSFRUUF9NRVRIT0RfR0VUEAESFAoQSFRUUF9NRVRIT0RfUE9TVBACEhQKEEhUVFBfTUVUSE9EX0hFQUQQAxITCg9IVFRQX01FVEhPRF9QVVQQBBIVChFIVFRQX01FVEhPRF9QQVRDSBAFEhYKEkhUVFBfTUVUSE9EX0RFTEVURRAGEhUKEUhUVFBfTUVUSE9EX1RSQUNFEAcSFwoTSFRUUF9NRVRIT0RfQ09OTkVDVBAIEhcKE0hUVFBfTUVUSE9EX09QVElPTlMQCUJTWlFnaXRodWIuY29tL29wZW5zdGF0dXNocS9vcGVuc3RhdHVzL3BhY2thZ2VzL3Byb3RvL29wZW5zdGF0dXMvbW9uaXRvci92MTttb25pdG9ydjFiBnByb3RvMw", [file_buf_validate_validate, file_gnostic_openapi_v3_annotations, file_openstatus_monitor_v1_assertions, file_openstatus_monitor_v1_monitor]); + fileDesc("CihvcGVuc3RhdHVzL21vbml0b3IvdjEvaHR0cF9tb25pdG9yLnByb3RvEhVvcGVuc3RhdHVzLm1vbml0b3IudjEiWgoHSGVhZGVycxIoCgNrZXkYASABKAlCG7pHEToPEg1BdXRob3JpemF0aW9uukgEcgIQARIlCgV2YWx1ZRgCIAEoCUIWukcTOhESD0JlYXJlciB0b2tlbjEyMyJsChNPcGVuVGVsZW1ldHJ5Q29uZmlnEhoKCGVuZHBvaW50GAEgASgJQgi6SAVyAxiAEBI5CgdoZWFkZXJzGAIgAygLMh4ub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhlYWRlcnNCCLpIBZIBAhAUItsICgtIVFRQTW9uaXRvchIKCgJpZBgBIAEoCRI6CgRuYW1lGAIgASgJQiy6Rx86HRIbUHJvZHVjdGlvbiBBUEkgSGVhbHRoIENoZWNrukgHcgUQARiAAhI/CgN1cmwYAyABKAlCMrpHIjogEh5odHRwczovL2FwaS5leGFtcGxlLmNvbS9oZWFsdGi6SApyCBABGIAQiAEBEkEKC3BlcmlvZGljaXR5GAQgASgOMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLlBlcmlvZGljaXR5Qgi6SAWCAQIgABI7CgZtZXRob2QYBSABKA4yIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuSFRUUE1ldGhvZEIIukgFggECIAASJQoEYm9keRgGIAEoCUIXukcUOhISEHsia2V5IjogInZhbHVlIn0SHAoHdGltZW91dBgHIAEoA0ILukgIIgYYwKkHKAASJQoLZGVncmFkZWRfYXQYCCABKANCC7pICCIGGMCpBygASACIAQESGAoFcmV0cnkYCSABKANCCbpIBiIEGAooABIdChBmb2xsb3dfcmVkaXJlY3RzGAogASgISAGIAQESOQoHaGVhZGVycxgLIAMoCzIeLm9wZW5zdGF0dXMubW9uaXRvci52MS5IZWFkZXJzQgi6SAWSAQIQFBJUChZzdGF0dXNfY29kZV9hc3NlcnRpb25zGAwgAygLMioub3BlbnN0YXR1cy5tb25pdG9yLnYxLlN0YXR1c0NvZGVBc3NlcnRpb25CCLpIBZIBAhAKEkcKD2JvZHlfYXNzZXJ0aW9ucxgNIAMoCzIkLm9wZW5zdGF0dXMubW9uaXRvci52MS5Cb2R5QXNzZXJ0aW9uQgi6SAWSAQIQChJLChFoZWFkZXJfYXNzZXJ0aW9ucxgOIAMoCzImLm9wZW5zdGF0dXMubW9uaXRvci52MS5IZWFkZXJBc3NlcnRpb25CCLpIBZIBAhAKEiIKC2Rlc2NyaXB0aW9uGA8gASgJQgi6SAVyAxiACEgCiAEBEhMKBmFjdGl2ZRgQIAEoCEgDiAEBEhMKBnB1YmxpYxgRIAEoCEgEiAEBEj8KB3JlZ2lvbnMYEiADKA4yHS5vcGVuc3RhdHVzLm1vbml0b3IudjEuUmVnaW9uQg+6SAySAQkQHCIFggECIAASQgoOb3Blbl90ZWxlbWV0cnkYEyABKAsyKi5vcGVuc3RhdHVzLm1vbml0b3IudjEuT3BlblRlbGVtZXRyeUNvbmZpZxI0CgZzdGF0dXMYFCABKA4yJC5vcGVuc3RhdHVzLm1vbml0b3IudjEuTW9uaXRvclN0YXR1cxIjChRwcml2YXRlX2xvY2F0aW9uX2lkcxgVIAMoCUIFukcCGAFCDgoMX2RlZ3JhZGVkX2F0QhMKEV9mb2xsb3dfcmVkaXJlY3RzQg4KDF9kZXNjcmlwdGlvbkIJCgdfYWN0aXZlQgkKB19wdWJsaWMq9wEKCkhUVFBNZXRob2QSGwoXSFRUUF9NRVRIT0RfVU5TUEVDSUZJRUQQABITCg9IVFRQX01FVEhPRF9HRVQQARIUChBIVFRQX01FVEhPRF9QT1NUEAISFAoQSFRUUF9NRVRIT0RfSEVBRBADEhMKD0hUVFBfTUVUSE9EX1BVVBAEEhUKEUhUVFBfTUVUSE9EX1BBVENIEAUSFgoSSFRUUF9NRVRIT0RfREVMRVRFEAYSFQoRSFRUUF9NRVRIT0RfVFJBQ0UQBxIXChNIVFRQX01FVEhPRF9DT05ORUNUEAgSFwoTSFRUUF9NRVRIT0RfT1BUSU9OUxAJQlNaUWdpdGh1Yi5jb20vb3BlbnN0YXR1c2hxL29wZW5zdGF0dXMvcGFja2FnZXMvcHJvdG8vb3BlbnN0YXR1cy9tb25pdG9yL3YxO21vbml0b3J2MWIGcHJvdG8z", [file_buf_validate_validate, file_gnostic_openapi_v3_annotations, file_openstatus_monitor_v1_assertions, file_openstatus_monitor_v1_monitor]); /** * Headers represents a key-value pair for HTTP headers. @@ -181,23 +181,23 @@ export type HTTPMonitor = Message<"openstatus.monitor.v1.HTTPMonitor"> & { /** * Description of the monitor (optional). * - * @generated from field: string description = 15; + * @generated from field: optional string description = 15; */ - description: string; + description?: string | undefined; /** * Whether the monitor is active (defaults to false). * - * @generated from field: bool active = 16; + * @generated from field: optional bool active = 16; */ - active: boolean; + active?: boolean | undefined; /** * Whether the monitor is publicly visible (defaults to false). * - * @generated from field: bool public = 17; + * @generated from field: optional bool public = 17; */ - public: boolean; + public?: boolean | undefined; /** * Geographic regions to run checks from. diff --git a/packages/proto/gen/ts/openstatus/monitor/v1/tcp_monitor_pb.ts b/packages/proto/gen/ts/openstatus/monitor/v1/tcp_monitor_pb.ts index 5148cbc8..74aedce4 100644 --- a/packages/proto/gen/ts/openstatus/monitor/v1/tcp_monitor_pb.ts +++ b/packages/proto/gen/ts/openstatus/monitor/v1/tcp_monitor_pb.ts @@ -16,7 +16,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file openstatus/monitor/v1/tcp_monitor.proto. */ export const file_openstatus_monitor_v1_tcp_monitor: GenFile = /*@__PURE__*/ - fileDesc("CidvcGVuc3RhdHVzL21vbml0b3IvdjEvdGNwX21vbml0b3IucHJvdG8SFW9wZW5zdGF0dXMubW9uaXRvci52MSLcBAoKVENQTW9uaXRvchIKCgJpZBgBIAEoCRI4CgRuYW1lGAIgASgJQiq6Rx06GxIZRGF0YWJhc2UgQ29ubmVjdGlvbiBDaGVja7pIB3IFEAEYgAISNwoDdXJpGAMgASgJQiq6Rx06GxIZdGNwOi8vZGIuZXhhbXBsZS5jb206NTQzMrpIB3IFEAEYgBASQQoLcGVyaW9kaWNpdHkYBCABKA4yIi5vcGVuc3RhdHVzLm1vbml0b3IudjEuUGVyaW9kaWNpdHlCCLpIBYIBAiAAEhwKB3RpbWVvdXQYBSABKANCC7pICCIGGMCpBygAEiUKC2RlZ3JhZGVkX2F0GAYgASgDQgu6SAgiBhjAqQcoAEgAiAEBEhgKBXJldHJ5GAcgASgDQgm6SAYiBBgKKAASHQoLZGVzY3JpcHRpb24YCCABKAlCCLpIBXIDGIAIEg4KBmFjdGl2ZRgJIAEoCBIOCgZwdWJsaWMYCiABKAgSPwoHcmVnaW9ucxgLIAMoDjIdLm9wZW5zdGF0dXMubW9uaXRvci52MS5SZWdpb25CD7pIDJIBCRAcIgWCAQIgABJCCg5vcGVuX3RlbGVtZXRyeRgMIAEoCzIqLm9wZW5zdGF0dXMubW9uaXRvci52MS5PcGVuVGVsZW1ldHJ5Q29uZmlnEjQKBnN0YXR1cxgNIAEoDjIkLm9wZW5zdGF0dXMubW9uaXRvci52MS5Nb25pdG9yU3RhdHVzEiMKFHByaXZhdGVfbG9jYXRpb25faWRzGA4gAygJQgW6RwIYAUIOCgxfZGVncmFkZWRfYXRCU1pRZ2l0aHViLmNvbS9vcGVuc3RhdHVzaHEvb3BlbnN0YXR1cy9wYWNrYWdlcy9wcm90by9vcGVuc3RhdHVzL21vbml0b3IvdjE7bW9uaXRvcnYxYgZwcm90bzM", [file_buf_validate_validate, file_gnostic_openapi_v3_annotations, file_openstatus_monitor_v1_http_monitor, file_openstatus_monitor_v1_monitor]); + fileDesc("CidvcGVuc3RhdHVzL21vbml0b3IvdjEvdGNwX21vbml0b3IucHJvdG8SFW9wZW5zdGF0dXMubW9uaXRvci52MSKRBQoKVENQTW9uaXRvchIKCgJpZBgBIAEoCRI4CgRuYW1lGAIgASgJQiq6Rx06GxIZRGF0YWJhc2UgQ29ubmVjdGlvbiBDaGVja7pIB3IFEAEYgAISNwoDdXJpGAMgASgJQiq6Rx06GxIZdGNwOi8vZGIuZXhhbXBsZS5jb206NTQzMrpIB3IFEAEYgBASQQoLcGVyaW9kaWNpdHkYBCABKA4yIi5vcGVuc3RhdHVzLm1vbml0b3IudjEuUGVyaW9kaWNpdHlCCLpIBYIBAiAAEhwKB3RpbWVvdXQYBSABKANCC7pICCIGGMCpBygAEiUKC2RlZ3JhZGVkX2F0GAYgASgDQgu6SAgiBhjAqQcoAEgAiAEBEhgKBXJldHJ5GAcgASgDQgm6SAYiBBgKKAASIgoLZGVzY3JpcHRpb24YCCABKAlCCLpIBXIDGIAISAGIAQESEwoGYWN0aXZlGAkgASgISAKIAQESEwoGcHVibGljGAogASgISAOIAQESPwoHcmVnaW9ucxgLIAMoDjIdLm9wZW5zdGF0dXMubW9uaXRvci52MS5SZWdpb25CD7pIDJIBCRAcIgWCAQIgABJCCg5vcGVuX3RlbGVtZXRyeRgMIAEoCzIqLm9wZW5zdGF0dXMubW9uaXRvci52MS5PcGVuVGVsZW1ldHJ5Q29uZmlnEjQKBnN0YXR1cxgNIAEoDjIkLm9wZW5zdGF0dXMubW9uaXRvci52MS5Nb25pdG9yU3RhdHVzEiMKFHByaXZhdGVfbG9jYXRpb25faWRzGA4gAygJQgW6RwIYAUIOCgxfZGVncmFkZWRfYXRCDgoMX2Rlc2NyaXB0aW9uQgkKB19hY3RpdmVCCQoHX3B1YmxpY0JTWlFnaXRodWIuY29tL29wZW5zdGF0dXNocS9vcGVuc3RhdHVzL3BhY2thZ2VzL3Byb3RvL29wZW5zdGF0dXMvbW9uaXRvci92MTttb25pdG9ydjFiBnByb3RvMw", [file_buf_validate_validate, file_gnostic_openapi_v3_annotations, file_openstatus_monitor_v1_http_monitor, file_openstatus_monitor_v1_monitor]); /** * TCPMonitor defines the configuration for a TCP monitor. @@ -76,23 +76,23 @@ export type TCPMonitor = Message<"openstatus.monitor.v1.TCPMonitor"> & { /** * Description of the monitor (optional). * - * @generated from field: string description = 8; + * @generated from field: optional string description = 8; */ - description: string; + description?: string | undefined; /** * Whether the monitor is active (defaults to false). * - * @generated from field: bool active = 9; + * @generated from field: optional bool active = 9; */ - active: boolean; + active?: boolean | undefined; /** * Whether the monitor is publicly visible (defaults to false). * - * @generated from field: bool public = 10; + * @generated from field: optional bool public = 10; */ - public: boolean; + public?: boolean | undefined; /** * Geographic regions to run checks from. diff --git a/packages/proto/package.json b/packages/proto/package.json index be04f46c..3cc01f69 100644 --- a/packages/proto/package.json +++ b/packages/proto/package.json @@ -44,6 +44,7 @@ "buf:ts": "buf generate --path api/openstatus --template buf.gen.ts.yaml", "buf:go": "buf generate --path internal/private_location --template buf.gen.go.yaml", "buf:lint": "buf lint", + "buf:lint:api": "buf lint api", "build": "buf generate", "clean": "rm -rf gen/ts" }, -- 2.51.2