From 963b83071278eea77aaf4678b9dec9cc37002922 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:18:45 +0200 Subject: [PATCH] fix: api server key scope (#2564) * fix: api server key scope * fix: review * refactor: assertWithinLimit * wip: limits --- .../monitor/converters/assertions.test.ts | 204 +++++++ .../handlers/monitor/converters/assertions.ts | 144 +++-- .../monitor/converters/comparators.ts | 28 +- .../monitor/converters/headers.test.ts | 92 +++ .../handlers/monitor/converters/headers.ts | 23 +- .../rpc/handlers/monitor/converters/index.ts | 9 +- .../src/routes/rpc/handlers/monitor/errors.ts | 35 -- .../src/routes/rpc/handlers/monitor/index.ts | 338 ++++------- .../rpc/handlers/monitor/validators.test.ts | 169 ++++++ .../routes/rpc/handlers/monitor/validators.ts | 38 +- .../routes/rpc/handlers/status-page/errors.ts | 48 -- .../routes/rpc/handlers/status-page/index.ts | 283 ++++----- .../src/routes/rpc/interceptors/auth.ts | 49 +- .../src/routes/rpc/interceptors/context.ts | 45 ++ packages/services/package.json | 4 + .../services/src/__tests__/limits.test.ts | 227 ++++++++ packages/services/src/errors.ts | 7 +- packages/services/src/index.ts | 7 +- packages/services/src/limits.ts | 159 ++++- .../src/monitor/__tests__/monitor.test.ts | 548 +++++++++++++++++- packages/services/src/monitor/clone.ts | 14 +- packages/services/src/monitor/create.ts | 20 +- packages/services/src/monitor/index.ts | 4 + packages/services/src/monitor/internal.ts | 15 +- packages/services/src/monitor/schemas.ts | 59 +- packages/services/src/monitor/trigger.ts | 110 ++++ packages/services/src/monitor/update.ts | 70 +++ .../__tests__/notification.test.ts | 21 + packages/services/src/notification/create.ts | 22 +- .../__tests__/page-component-group.test.ts | 244 ++++++++ .../src/page-component-group/create.ts | 44 ++ .../src/page-component-group/delete.ts | 40 ++ .../src/page-component-group/index.ts | 9 + .../src/page-component-group/internal.ts | 26 + .../src/page-component-group/schemas.ts | 29 + .../src/page-component-group/update.ts | 46 ++ .../__tests__/page-component.test.ts | 401 ++++++++++++- .../services/src/page-component/create.ts | 111 ++++ packages/services/src/page-component/index.ts | 4 + .../services/src/page-component/internal.ts | 55 +- .../services/src/page-component/schemas.ts | 39 ++ .../services/src/page-component/update.ts | 60 ++ .../__tests__/page-subscriber.test.ts | 191 ++++++ .../services/src/page-subscriber/index.ts | 2 + .../services/src/page-subscriber/schemas.ts | 19 + .../unsubscribe-in-workspace.ts | 79 +++ .../services/src/page/__tests__/page.test.ts | 44 ++ packages/services/src/page/create.ts | 14 +- packages/services/src/page/internal.ts | 29 +- 49 files changed, 3555 insertions(+), 723 deletions(-) create mode 100644 apps/server/src/routes/rpc/handlers/monitor/converters/assertions.test.ts create mode 100644 apps/server/src/routes/rpc/handlers/monitor/converters/headers.test.ts create mode 100644 apps/server/src/routes/rpc/handlers/monitor/validators.test.ts create mode 100644 apps/server/src/routes/rpc/interceptors/context.ts create mode 100644 packages/services/src/__tests__/limits.test.ts create mode 100644 packages/services/src/monitor/trigger.ts create mode 100644 packages/services/src/page-component-group/__tests__/page-component-group.test.ts create mode 100644 packages/services/src/page-component-group/create.ts create mode 100644 packages/services/src/page-component-group/delete.ts create mode 100644 packages/services/src/page-component-group/index.ts create mode 100644 packages/services/src/page-component-group/internal.ts create mode 100644 packages/services/src/page-component-group/schemas.ts create mode 100644 packages/services/src/page-component-group/update.ts create mode 100644 packages/services/src/page-component/create.ts create mode 100644 packages/services/src/page-component/update.ts create mode 100644 packages/services/src/page-subscriber/unsubscribe-in-workspace.ts diff --git a/apps/server/src/routes/rpc/handlers/monitor/converters/assertions.test.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/assertions.test.ts new file mode 100644 index 00000000..30001928 --- /dev/null +++ b/apps/server/src/routes/rpc/handlers/monitor/converters/assertions.test.ts @@ -0,0 +1,204 @@ +import { assertion } from "@openstatus/assertions"; +import { + type BodyAssertion, + type HeaderAssertion, + NumberComparator, + type RecordAssertion, + RecordComparator, + type StatusCodeAssertion, + StringComparator, +} from "@openstatus/proto/monitor/v1"; +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; +import { z } from "zod"; + +import { + type MonitorAssertionInput, + parseDnsAssertions, + parseHttpAssertions, + protoDnsAssertionsToService, + protoHttpAssertionsToService, +} from "./assertions"; + +/** + * Mirrors what the service stores: `serialize()` is + * `JSON.stringify(assertions.map((a) => a.schema))`, and the schemas are + * exactly what the converters emit. Inlined so the test doesn't have to + * build throwaway `Assertion` class instances just to unwrap them again. + */ +function store(input: MonitorAssertionInput[]): string { + return JSON.stringify(z.array(assertion).parse(input)); +} + +function statusAssertion( + target: number, + comparator: NumberComparator, +): StatusCodeAssertion { + return { + $typeName: "openstatus.monitor.v1.StatusCodeAssertion", + target: BigInt(target), + comparator, + }; +} + +function bodyAssertion( + target: string, + comparator: StringComparator, +): BodyAssertion { + return { + $typeName: "openstatus.monitor.v1.BodyAssertion", + target, + comparator, + }; +} + +function headerAssertion( + key: string, + target: string, + comparator: StringComparator, +): HeaderAssertion { + return { + $typeName: "openstatus.monitor.v1.HeaderAssertion", + key, + target, + comparator, + }; +} + +function recordAssertion( + record: string, + target: string, + comparator: RecordComparator, +): RecordAssertion { + return { + $typeName: "openstatus.monitor.v1.RecordAssertion", + record, + target, + comparator, + }; +} + +describe("protoHttpAssertionsToService", () => { + test("returns an empty list when nothing is asserted", () => { + expect(protoHttpAssertionsToService([], [], [])).toEqual([]); + }); + + test("converts each assertion kind", () => { + const result = protoHttpAssertionsToService( + [statusAssertion(200, NumberComparator.EQUAL)], + [bodyAssertion("ok", StringComparator.CONTAINS)], + [headerAssertion("content-type", "json", StringComparator.NOT_EQUAL)], + ); + + expect(result).toEqual([ + { version: "v1", type: "status", compare: "eq", target: 200 }, + { version: "v1", type: "textBody", compare: "contains", target: "ok" }, + { + version: "v1", + type: "header", + compare: "not_eq", + target: "json", + key: "content-type", + }, + ]); + }); + + test("output is accepted by the service's assertion schema", () => { + // The contract that matters: whatever this emits must survive + // `CreateMonitorInput`'s parse, or every create 400s at the service. + const result = protoHttpAssertionsToService( + [ + statusAssertion(200, NumberComparator.EQUAL), + statusAssertion(500, NumberComparator.LESS_THAN), + ], + [bodyAssertion("healthy", StringComparator.NOT_CONTAINS)], + [headerAssertion("x-cache", "HIT", StringComparator.EQUAL)], + ); + + expect(() => z.array(assertion).parse(result)).not.toThrow(); + }); + + test("survives a full round-trip back to proto", () => { + const status = statusAssertion(201, NumberComparator.GREATER_THAN_OR_EQUAL); + const body = bodyAssertion("pong", StringComparator.EQUAL); + const header = headerAssertion("etag", "abc", StringComparator.CONTAINS); + + const stored = store( + protoHttpAssertionsToService([status], [body], [header]), + ); + const back = parseHttpAssertions(stored); + + expect(back.statusCodeAssertions[0]?.target).toBe(BigInt(201)); + expect(back.statusCodeAssertions[0]?.comparator).toBe( + NumberComparator.GREATER_THAN_OR_EQUAL, + ); + expect(back.bodyAssertions[0]?.target).toBe("pong"); + expect(back.headerAssertions[0]?.key).toBe("etag"); + expect(back.headerAssertions[0]?.comparator).toBe( + StringComparator.CONTAINS, + ); + }); + + test("an unspecified comparator falls back to eq", () => { + const result = protoHttpAssertionsToService( + [statusAssertion(200, NumberComparator.UNSPECIFIED)], + [], + [], + ); + expect(result[0]).toMatchObject({ compare: "eq" }); + }); +}); + +describe("protoDnsAssertionsToService", () => { + test("returns an empty list when nothing is asserted", () => { + expect(protoDnsAssertionsToService([])).toEqual([]); + }); + + test("maps the record type onto the assertion key", () => { + const result = protoDnsAssertionsToService([ + recordAssertion("CNAME", "example.com", RecordComparator.EQUAL), + ]); + + expect(result).toEqual([ + { + version: "v1", + type: "dnsRecord", + compare: "eq", + target: "example.com", + key: "CNAME", + }, + ]); + }); + + test("output is accepted by the service's assertion schema", () => { + const result = protoDnsAssertionsToService([ + recordAssertion("A", "1.2.3.4", RecordComparator.NOT_CONTAINS), + recordAssertion("TXT", "v=spf1", RecordComparator.CONTAINS), + ]); + + expect(() => z.array(assertion).parse(result)).not.toThrow(); + }); + + test("survives a full round-trip back to proto", () => { + const stored = store( + protoDnsAssertionsToService([ + recordAssertion("MX", "mail.example.com", RecordComparator.EQUAL), + ]), + ); + const back = parseDnsAssertions(stored); + + expect(back[0]?.record).toBe("MX"); + expect(back[0]?.target).toBe("mail.example.com"); + expect(back[0]?.comparator).toBe(RecordComparator.EQUAL); + }); + + test("rejects a record type outside the assertion enum", () => { + // protovalidate should have caught this upstream; if it didn't, fail + // loudly rather than writing an unparseable row. + expect(() => + protoDnsAssertionsToService([ + recordAssertion("SRV", "x", RecordComparator.EQUAL), + ]), + ).toThrow(); + }); +}); diff --git a/apps/server/src/routes/rpc/handlers/monitor/converters/assertions.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/assertions.ts index 7005f928..5ca3cbed 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/converters/assertions.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/converters/assertions.ts @@ -1,10 +1,15 @@ +import { Code, ConnectError } from "@connectrpc/connect"; import { getLogger } from "@logtape/logtape"; import { type Assertion, deserialize, + dnsRecords, headerAssertion, + type numberCompare, recordAssertion, + type recordCompare, statusAssertion, + type stringCompare, textBodyAssertion, } from "@openstatus/assertions"; import type { @@ -13,6 +18,7 @@ import type { RecordAssertion, StatusCodeAssertion, } from "@openstatus/proto/monitor/v1"; +import type { z } from "zod"; import { compareToNumberComparator, @@ -23,6 +29,11 @@ import { stringComparatorToString, } from "./comparators"; +type NumberCompare = z.infer; +type StringCompare = z.infer; +type RecordCompare = z.infer; +type DnsRecord = (typeof dnsRecords)[number]; + const logger = getLogger("api-server"); // ============================================================ @@ -137,69 +148,92 @@ export function parseDnsAssertions( } // ============================================================ -// Proto to DB (for writes) +// Proto to service input (for writes) // ============================================================ +/** The service layer's assertion input — serialising is its job, not ours. */ +export type MonitorAssertionInput = + | { version: "v1"; type: "status"; compare: NumberCompare; target: number } + | { version: "v1"; type: "textBody"; compare: StringCompare; target: string } + | { + version: "v1"; + type: "header"; + compare: StringCompare; + target: string; + key: string; + } + | { + version: "v1"; + type: "dnsRecord"; + compare: RecordCompare; + target: string; + key: DnsRecord; + }; + /** - * Convert HTTP monitor proto assertions to database JSON string. - * Uses @openstatus/assertions package format. + * Narrow the proto's free-string record type to the assertion enum. + * protovalidate already restricts the field, so a miss here means the + * validation interceptor was bypassed. */ -export function httpAssertionsToDbJson( +function toDnsRecordKey(record: string): DnsRecord { + const match = dnsRecords.find((r) => r === record); + if (!match) { + throw new ConnectError( + `Invalid DNS record type: ${record}`, + Code.InvalidArgument, + ); + } + return match; +} + +export function protoHttpAssertionsToService( statusCodeAssertions: StatusCodeAssertion[], bodyAssertions: BodyAssertion[], headerAssertions: HeaderAssertion[], -): string | undefined { - const schemas: Array> = []; - - for (const s of statusCodeAssertions) { - schemas.push({ - version: "v1", - type: "status", - compare: numberComparatorToString(s.comparator), - target: Number(s.target), - }); - } - - for (const b of bodyAssertions) { - schemas.push({ - version: "v1", - type: "textBody", - compare: stringComparatorToString(b.comparator), - target: b.target, - }); - } - - for (const h of headerAssertions) { - schemas.push({ - version: "v1", - type: "header", - compare: stringComparatorToString(h.comparator), - target: h.target, - key: h.key, - }); - } - - return schemas.length > 0 ? JSON.stringify(schemas) : undefined; +): MonitorAssertionInput[] { + return [ + ...statusCodeAssertions.map( + (s) => + ({ + version: "v1", + type: "status", + compare: numberComparatorToString(s.comparator), + target: Number(s.target), + }) as const, + ), + ...bodyAssertions.map( + (b) => + ({ + version: "v1", + type: "textBody", + compare: stringComparatorToString(b.comparator), + target: b.target, + }) as const, + ), + ...headerAssertions.map( + (h) => + ({ + version: "v1", + type: "header", + compare: stringComparatorToString(h.comparator), + target: h.target, + key: h.key, + }) as const, + ), + ]; } -/** - * Convert DNS monitor proto assertions to database JSON string. - * Uses @openstatus/assertions package format with dnsRecord type. - */ -export function dnsAssertionsToDbJson( +export function protoDnsAssertionsToService( recordAssertions: RecordAssertion[], -): string | undefined { - if (recordAssertions.length === 0) { - return undefined; - } - - const schemas = recordAssertions.map((a) => ({ - version: "v1", - type: "dnsRecord", - compare: recordComparatorToString(a.comparator), - target: a.target, - key: a.record, - })); - - return JSON.stringify(schemas); +): MonitorAssertionInput[] { + return recordAssertions.map( + (a) => + ({ + version: "v1", + type: "dnsRecord", + compare: recordComparatorToString(a.comparator), + target: a.target, + key: toDnsRecordKey(a.record), + }) as const, + ); } diff --git a/apps/server/src/routes/rpc/handlers/monitor/converters/comparators.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/comparators.ts index cb8f450d..b29dbb41 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/converters/comparators.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/converters/comparators.ts @@ -1,8 +1,18 @@ +import type { + numberCompare, + recordCompare, + stringCompare, +} from "@openstatus/assertions"; import { NumberComparator, RecordComparator, StringComparator, } from "@openstatus/proto/monitor/v1"; +import type { z } from "zod"; + +type NumberCompare = z.infer; +type StringCompare = z.infer; +type RecordCompare = z.infer; // ============================================================ // DB to Proto (for reads) @@ -53,7 +63,7 @@ export function compareToRecordComparator(compare: string): RecordComparator { // Proto to DB (for writes) // ============================================================ -const NUMBER_COMPARATOR_TO_DB: Record = { +const NUMBER_COMPARATOR_TO_DB: Record = { [NumberComparator.EQUAL]: "eq", [NumberComparator.NOT_EQUAL]: "not_eq", [NumberComparator.GREATER_THAN]: "gt", @@ -63,7 +73,7 @@ const NUMBER_COMPARATOR_TO_DB: Record = { [NumberComparator.UNSPECIFIED]: "eq", }; -const STRING_COMPARATOR_TO_DB: Record = { +const STRING_COMPARATOR_TO_DB: Record = { [StringComparator.EQUAL]: "eq", [StringComparator.NOT_EQUAL]: "not_eq", [StringComparator.CONTAINS]: "contains", @@ -77,7 +87,7 @@ const STRING_COMPARATOR_TO_DB: Record = { [StringComparator.UNSPECIFIED]: "eq", }; -const RECORD_COMPARATOR_TO_DB: Record = { +const RECORD_COMPARATOR_TO_DB: Record = { [RecordComparator.EQUAL]: "eq", [RecordComparator.NOT_EQUAL]: "not_eq", [RecordComparator.CONTAINS]: "contains", @@ -85,14 +95,20 @@ const RECORD_COMPARATOR_TO_DB: Record = { [RecordComparator.UNSPECIFIED]: "eq", }; -export function numberComparatorToString(comp: NumberComparator): string { +export function numberComparatorToString( + comp: NumberComparator, +): NumberCompare { return NUMBER_COMPARATOR_TO_DB[comp] ?? "eq"; } -export function stringComparatorToString(comp: StringComparator): string { +export function stringComparatorToString( + comp: StringComparator, +): StringCompare { return STRING_COMPARATOR_TO_DB[comp] ?? "eq"; } -export function recordComparatorToString(comp: RecordComparator): string { +export function recordComparatorToString( + comp: RecordComparator, +): RecordCompare { return RECORD_COMPARATOR_TO_DB[comp] ?? "eq"; } diff --git a/apps/server/src/routes/rpc/handlers/monitor/converters/headers.test.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/headers.test.ts new file mode 100644 index 00000000..a0eebfb8 --- /dev/null +++ b/apps/server/src/routes/rpc/handlers/monitor/converters/headers.test.ts @@ -0,0 +1,92 @@ +import type { + Headers, + OpenTelemetryConfig, +} from "@openstatus/proto/monitor/v1"; +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { + parseOpenTelemetry, + protoHeadersToService, + protoOpenTelemetryToService, + toProtoHeaders, +} from "./headers"; + +function header(key: string, value: string): Headers { + return { $typeName: "openstatus.monitor.v1.Headers", key, value }; +} + +function otelConfig(endpoint: string, headers: Headers[]): OpenTelemetryConfig { + return { + $typeName: "openstatus.monitor.v1.OpenTelemetryConfig", + endpoint, + headers, + }; +} + +describe("protoHeadersToService", () => { + test("returns undefined for an empty list so the column keeps its default", () => { + expect(protoHeadersToService([])).toBeUndefined(); + }); + + test("strips the proto wrapper", () => { + expect(protoHeadersToService([header("a", "1"), header("b", "2")])).toEqual( + [ + { key: "a", value: "1" }, + { key: "b", value: "2" }, + ], + ); + }); + + test("round-trips back to proto headers", () => { + const input = [header("x-token", "secret")]; + const back = toProtoHeaders(protoHeadersToService(input)); + expect(back).toEqual(input); + }); +}); + +describe("protoOpenTelemetryToService", () => { + test("returns both fields undefined when no config is set", () => { + expect(protoOpenTelemetryToService(undefined)).toEqual({ + otelEndpoint: undefined, + otelHeaders: undefined, + }); + }); + + test("treats an empty endpoint as no config", () => { + // An endpoint-less config must not write headers, or the monitor ends + // up with otel headers pointing at nothing. + expect( + protoOpenTelemetryToService(otelConfig("", [header("a", "1")])), + ).toEqual({ + otelEndpoint: undefined, + otelHeaders: undefined, + }); + }); + + test("passes the endpoint through and unwraps headers", () => { + expect( + protoOpenTelemetryToService( + otelConfig("https://otel.example.com", [ + header("authorization", "Bearer x"), + ]), + ), + ).toEqual({ + otelEndpoint: "https://otel.example.com", + otelHeaders: [{ key: "authorization", value: "Bearer x" }], + }); + }); + + test("round-trips back to a proto config", () => { + const endpoint = "https://otel.example.com"; + const headers = [header("a", "1")]; + const service = protoOpenTelemetryToService(otelConfig(endpoint, headers)); + const back = parseOpenTelemetry( + service.otelEndpoint ?? null, + service.otelHeaders, + ); + + expect(back?.endpoint).toBe(endpoint); + expect(back?.headers).toEqual(headers); + }); +}); diff --git a/apps/server/src/routes/rpc/handlers/monitor/converters/headers.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/headers.ts index 644105bc..ccd0e1da 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/converters/headers.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/converters/headers.ts @@ -43,26 +43,25 @@ export function parseOpenTelemetry( } // ============================================================ -// Proto to DB (for writes) +// Proto to service input (for writes) // ============================================================ -/** - * Convert proto Headers array to database JSON string. - */ -export function headersToDbJson(headers: Headers[]): string | undefined { +/** Strip the proto wrapper down to the `{key,value}[]` the services take. */ +export function protoHeadersToService( + headers: Headers[], +): Array<{ key: string; value: string }> | undefined { if (headers.length === 0) { return undefined; } - return JSON.stringify(headers.map((h) => ({ key: h.key, value: h.value }))); + return headers.map((h) => ({ key: h.key, value: h.value })); } -/** - * Convert OpenTelemetry config to database fields. - */ -export function openTelemetryToDb(config: OpenTelemetryConfig | undefined): { +export function protoOpenTelemetryToService( + config: OpenTelemetryConfig | undefined, +): { otelEndpoint: string | undefined; - otelHeaders: string | undefined; + otelHeaders: Array<{ key: string; value: string }> | undefined; } { if (!config || !config.endpoint) { return { @@ -73,6 +72,6 @@ export function openTelemetryToDb(config: OpenTelemetryConfig | undefined): { return { otelEndpoint: config.endpoint, - otelHeaders: headersToDbJson(config.headers), + otelHeaders: protoHeadersToService(config.headers), }; } diff --git a/apps/server/src/routes/rpc/handlers/monitor/converters/index.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/index.ts index 93e79d16..dffcab83 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/converters/index.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/converters/index.ts @@ -4,9 +4,10 @@ export { parseHttpAssertions, parseDnsAssertions, - httpAssertionsToDbJson, - dnsAssertionsToDbJson, + protoHttpAssertionsToService, + protoDnsAssertionsToService, type HttpAssertions, + type MonitorAssertionInput, } from "./assertions"; // Comparators @@ -37,8 +38,8 @@ export { export { toProtoHeaders, parseOpenTelemetry, - headersToDbJson, - openTelemetryToDb, + protoHeadersToService, + protoOpenTelemetryToService, } from "./headers"; // Monitors diff --git a/apps/server/src/routes/rpc/handlers/monitor/errors.ts b/apps/server/src/routes/rpc/handlers/monitor/errors.ts index ac512618..05faa122 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/errors.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/errors.ts @@ -82,29 +82,6 @@ export function monitorIdRequiredError(): ConnectError { ); } -/** - * Creates a "failed to create monitor" error. - */ -export function monitorCreateFailedError(): ConnectError { - return createError( - "Failed to create monitor", - Code.Internal, - ErrorReason.MONITOR_CREATE_FAILED, - ); -} - -/** - * Creates a "failed to update monitor" error. - */ -export function monitorUpdateFailedError(monitorId: string): ConnectError { - return createError( - "Failed to update monitor", - Code.Internal, - ErrorReason.MONITOR_UPDATE_FAILED, - { "monitor-id": monitorId }, - ); -} - /** * Creates a "monitor type mismatch" error when trying to update with wrong type. */ @@ -163,18 +140,6 @@ export function monitorParseFailedError(monitorId?: string): ConnectError { ); } -/** - * Creates a "failed to create monitor run" error. - */ -export function monitorRunCreateFailedError(monitorId: string): ConnectError { - return createError( - "Failed to create monitor run", - Code.Internal, - ErrorReason.MONITOR_RUN_CREATE_FAILED, - { "monitor-id": monitorId }, - ); -} - /** * Creates an "invalid monitor data" error for corrupted data. */ diff --git a/apps/server/src/routes/rpc/handlers/monitor/index.ts b/apps/server/src/routes/rpc/handlers/monitor/index.ts index fa345834..351ab90e 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/index.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/index.ts @@ -1,7 +1,6 @@ import type { ServiceImpl } from "@connectrpc/connect"; -import { and, db, eq, gte, isNull, sql } from "@openstatus/db"; -import { monitor, monitorRun } from "@openstatus/db/src/schema"; -import { monitorStatusTable } from "@openstatus/db/src/schema/monitor_status/monitor_status"; +import { and, db, eq, isNull, sql } from "@openstatus/db"; +import { monitor } from "@openstatus/db/src/schema"; import { selectMonitorSchema } from "@openstatus/db/src/schema/monitors/validation"; import type { DNSMonitor, @@ -18,17 +17,22 @@ import type { import { TimeRange } from "@openstatus/proto/monitor/v1"; import { ForbiddenError, + LimitExceededError, NotFoundError, ValidationError, } from "@openstatus/services"; import { type MonitorTimeRange, + type UpdateMonitorConfigInput, + createMonitor, deleteMonitor, getMonitorStatus, getMonitorSummary, getPrivateLocationIdsByMonitor, getResponseLog, listResponseLogs, + triggerMonitorRun, + updateMonitorConfig, } from "@openstatus/services/monitor"; import { env } from "../../../../env"; @@ -38,15 +42,15 @@ import { getCheckerUrl, } from "../../../../libs/checker"; import { toConnectError, toServiceCtx } from "../../adapter"; -import { getRpcContext } from "../../interceptors"; +import { type RpcContext, getRpcContext } from "../../interceptors"; import { MONITOR_DEFAULTS, dbMonitorToDnsProto, dbMonitorToHttpProto, dbMonitorToTcpProto, - dnsAssertionsToDbJson, - headersToDbJson, - httpAssertionsToDbJson, + protoDnsAssertionsToService, + protoHeadersToService, + protoHttpAssertionsToService, httpMethodToString, regionsToStrings, stringToMonitorStatus, @@ -55,15 +59,12 @@ import { timeRangeToKey, } from "./converters"; import { - monitorCreateFailedError, monitorIdRequiredError, monitorInvalidDataError, monitorNotFoundError, monitorParseFailedError, monitorRequiredError, - monitorRunCreateFailedError, monitorTypeMismatchError, - monitorUpdateFailedError, rateLimitExceededError, responseLogNotFoundError, responseLogsNotEnabledError, @@ -74,8 +75,8 @@ import { toHTTPResponseLogListItem, } from "./response-logs"; import { - getCommonDbValues, - getCommonDbValuesForUpdate, + getCommonCreateInput, + getCommonUpdateInput, toValidMethod, validateCommonMonitorFields, } from "./validators"; @@ -126,32 +127,22 @@ async function validateAndGetMonitor( type ParsedMonitor = ReturnType; -/** - * Helper to perform update and return the updated monitor. - */ -async function performUpdateAndReturn( +/** Apply a built patch through the service and shape the proto response. */ +async function applyUpdate( + rpcCtx: RpcContext, monitorId: number, - requestId: string, - updateValues: Record, + updateValues: Omit, converter: (data: ParsedMonitor) => T, ): Promise<{ monitor: T }> { - const updatedMonitor = await db - .update(monitor) - .set(updateValues) - .where(eq(monitor.id, monitorId)) - .returning() - .get(); - - if (!updatedMonitor) { - throw monitorUpdateFailedError(requestId); - } - - const parsed = selectMonitorSchema.safeParse(updatedMonitor); - if (!parsed.success) { - throw monitorParseFailedError(requestId); + try { + const updated = await updateMonitorConfig({ + ctx: toServiceCtx(rpcCtx), + input: { ...updateValues, id: monitorId }, + }); + return { monitor: converter(updated) }; + } catch (err) { + toConnectError(err); } - - return { monitor: converter(parsed.data) }; } /** @@ -175,48 +166,30 @@ export const monitorServiceImpl: ServiceImpl = { // Check workspace limits await checkMonitorLimits(workspaceId, limits, mon.periodicity, mon.regions); - // Get common DB values - const commonValues = getCommonDbValues(mon); - - // Convert headers and assertions to DB format - const headers = headersToDbJson(mon.headers); - const assertions = httpAssertionsToDbJson( - mon.statusCodeAssertions, - mon.bodyAssertions, - mon.headerAssertions, - ); - - // Insert into database - const newMonitor = await db - .insert(monitor) - .values({ - workspaceId, - jobType: "http", - url: mon.url, - method: toValidMethod(httpMethodToString(mon.method)), - body: mon.body || undefined, - headers, - assertions, - followRedirects: - mon.followRedirects ?? MONITOR_DEFAULTS.followRedirects, - ...commonValues, - }) - .returning() - .get(); - - if (!newMonitor) { - throw monitorCreateFailedError(); - } + try { + const created = await createMonitor({ + ctx: toServiceCtx(rpcCtx), + input: { + ...getCommonCreateInput(mon), + jobType: "http", + url: mon.url, + method: toValidMethod(httpMethodToString(mon.method)), + body: mon.body || undefined, + headers: protoHeadersToService(mon.headers) ?? [], + assertions: protoHttpAssertionsToService( + mon.statusCodeAssertions, + mon.bodyAssertions, + mon.headerAssertions, + ), + followRedirects: + mon.followRedirects ?? MONITOR_DEFAULTS.followRedirects, + }, + }); - // Parse through schema to transform fields - const parsed = selectMonitorSchema.safeParse(newMonitor); - if (!parsed.success) { - throw monitorParseFailedError(); + return { monitor: dbMonitorToHttpProto(created) }; + } catch (err) { + toConnectError(err); } - - return { - monitor: dbMonitorToHttpProto(parsed.data), - }; }, async createTCPMonitor(req, ctx) { @@ -236,34 +209,23 @@ export const monitorServiceImpl: ServiceImpl = { // Check workspace limits await checkMonitorLimits(workspaceId, limits, mon.periodicity, mon.regions); - // Get common DB values - const commonValues = getCommonDbValues(mon); - - // Insert into database - const newMonitor = await db - .insert(monitor) - .values({ - workspaceId, - jobType: "tcp", - url: mon.uri, - ...commonValues, - }) - .returning() - .get(); - - if (!newMonitor) { - throw monitorCreateFailedError(); - } + try { + const created = await createMonitor({ + ctx: toServiceCtx(rpcCtx), + input: { + ...getCommonCreateInput(mon), + jobType: "tcp", + url: mon.uri, + method: "GET", + headers: [], + assertions: [], + }, + }); - // Parse through schema to transform fields - const parsed = selectMonitorSchema.safeParse(newMonitor); - if (!parsed.success) { - throw monitorParseFailedError(); + return { monitor: dbMonitorToTcpProto(created) }; + } catch (err) { + toConnectError(err); } - - return { - monitor: dbMonitorToTcpProto(parsed.data), - }; }, async createDNSMonitor(req, ctx) { @@ -283,38 +245,23 @@ export const monitorServiceImpl: ServiceImpl = { // Check workspace limits await checkMonitorLimits(workspaceId, limits, mon.periodicity, mon.regions); - // Get common DB values - const commonValues = getCommonDbValues(mon); - - // Convert assertions to DB format - const assertions = dnsAssertionsToDbJson(mon.recordAssertions); - - // Insert into database - const newMonitor = await db - .insert(monitor) - .values({ - workspaceId, - jobType: "dns", - url: mon.uri, - assertions, - ...commonValues, - }) - .returning() - .get(); - - if (!newMonitor) { - throw monitorCreateFailedError(); - } + try { + const created = await createMonitor({ + ctx: toServiceCtx(rpcCtx), + input: { + ...getCommonCreateInput(mon), + jobType: "dns", + url: mon.uri, + method: "GET", + headers: [], + assertions: protoDnsAssertionsToService(mon.recordAssertions), + }, + }); - // Parse through schema to transform fields - const parsed = selectMonitorSchema.safeParse(newMonitor); - if (!parsed.success) { - throw monitorParseFailedError(); + return { monitor: dbMonitorToDnsProto(created) }; + } catch (err) { + toConnectError(err); } - - return { - monitor: dbMonitorToDnsProto(parsed.data), - }; }, async updateHTTPMonitor(req, ctx) { @@ -354,8 +301,7 @@ export const monitorServiceImpl: ServiceImpl = { ); // Build update values - only include fields that are provided - const updateValues: Record = - getCommonDbValuesForUpdate(mon); + const updateValues = getCommonUpdateInput(mon); // Handle HTTP-specific fields if (mon.url !== undefined && mon.url !== "") { @@ -375,23 +321,22 @@ export const monitorServiceImpl: ServiceImpl = { } if (mon.headers !== undefined) { - updateValues.headers = headersToDbJson(mon.headers); + updateValues.headers = protoHeadersToService(mon.headers); } - // Handle assertions - update if any assertion type is provided - if ( - mon.statusCodeAssertions !== undefined || - mon.bodyAssertions !== undefined || - mon.headerAssertions !== undefined - ) { - updateValues.assertions = httpAssertionsToDbJson( - mon.statusCodeAssertions ?? [], - mon.bodyAssertions ?? [], - mon.headerAssertions ?? [], - ); + // Repeated proto fields have no presence — they arrive as `[]` whether + // the caller omitted them or sent none. An empty result must therefore + // stay `undefined` (leave stored assertions alone) rather than clear it. + const assertions = protoHttpAssertionsToService( + mon.statusCodeAssertions ?? [], + mon.bodyAssertions ?? [], + mon.headerAssertions ?? [], + ); + if (assertions.length > 0) { + updateValues.assertions = assertions; } - return performUpdateAndReturn(dbMon.id, req.id, updateValues, (data) => + return applyUpdate(rpcCtx, dbMon.id, updateValues, (data) => dbMonitorToHttpProto(data, privateLocationIds), ); }, @@ -433,15 +378,14 @@ export const monitorServiceImpl: ServiceImpl = { ); // Build update values - only include fields that are provided - const updateValues: Record = - getCommonDbValuesForUpdate(mon); + const updateValues = getCommonUpdateInput(mon); // Handle TCP-specific fields if (mon.uri !== undefined && mon.uri !== "") { updateValues.url = mon.uri; } - return performUpdateAndReturn(dbMon.id, req.id, updateValues, (data) => + return applyUpdate(rpcCtx, dbMon.id, updateValues, (data) => dbMonitorToTcpProto(data, privateLocationIds), ); }, @@ -483,91 +427,61 @@ export const monitorServiceImpl: ServiceImpl = { ); // Build update values - only include fields that are provided - const updateValues: Record = - getCommonDbValuesForUpdate(mon); + const updateValues = getCommonUpdateInput(mon); // Handle DNS-specific fields if (mon.uri !== undefined && mon.uri !== "") { updateValues.url = mon.uri; } - // Handle DNS assertions - if (mon.recordAssertions !== undefined) { - updateValues.assertions = dnsAssertionsToDbJson(mon.recordAssertions); + // Empty means "not supplied" — see the note in `updateHTTPMonitor`. + const assertions = protoDnsAssertionsToService(mon.recordAssertions ?? []); + if (assertions.length > 0) { + updateValues.assertions = assertions; } - return performUpdateAndReturn(dbMon.id, req.id, updateValues, (data) => + return applyUpdate(rpcCtx, dbMon.id, updateValues, (data) => dbMonitorToDnsProto(data, privateLocationIds), ); }, async triggerMonitor(req, ctx) { const rpcCtx = getRpcContext(ctx); - const workspaceId = rpcCtx.workspace.id; const limits = rpcCtx.workspace.limits; - // Check rate limits - const lastMonth = new Date().setMonth(new Date().getMonth() - 1); - const countResult = await db - .select({ count: sql`count(*)` }) - .from(monitorRun) - .where( - and( - eq(monitorRun.workspaceId, workspaceId), - gte(monitorRun.createdAt, new Date(lastMonth)), - ), - ) - .get(); - - const count = countResult?.count ?? 0; - if (count >= limits["synthetic-checks"]) { - throw rateLimitExceededError(limits["synthetic-checks"], count); - } - - // Get the monitor - const dbMon = await getMonitorById(Number(req.id), workspaceId); - if (!dbMon) { - throw monitorNotFoundError(req.id); - } - - // Validate monitor data - const validateMonitor = selectMonitorSchema.safeParse(dbMon); - if (!validateMonitor.success) { - throw monitorInvalidDataError(req.id); + // The run is recorded first so a caller without write scope — or one + // over its quota — is rejected before any probe leaves the network. + let run: Awaited>; + try { + run = await triggerMonitorRun({ + ctx: toServiceCtx(rpcCtx), + input: { id: Number(req.id) }, + }); + } catch (err) { + if (err instanceof NotFoundError) { + throw monitorNotFoundError(req.id); + } + if (err instanceof ValidationError) { + throw monitorInvalidDataError(req.id); + } + if (err instanceof LimitExceededError) { + throw rateLimitExceededError( + limits["synthetic-checks"], + err.current ?? limits["synthetic-checks"], + ); + } + toConnectError(err); } - const row = validateMonitor.data; - - // Get monitor status for each region - const monitorStatuses = await db - .select() - .from(monitorStatusTable) - .where(eq(monitorStatusTable.monitorId, dbMon.id)) - .all(); - - // Create a monitor run record - const timestamp = Date.now(); - const newRun = await db - .insert(monitorRun) - .values({ - monitorId: row.id, - workspaceId: row.workspaceId, - runnedAt: new Date(timestamp), - }) - .returning() - .get(); - - if (!newRun) { - throw monitorRunCreateFailedError(req.id); - } + const row = run.monitor; + const url = getCheckerUrl(row); + const timeout = getCheckerTimeout(row); // Trigger checks for each region in parallel await Promise.all( - validateMonitor.data.regions.map((region) => { - const statusEntry = monitorStatuses.find((m) => region === m.region); - const status = statusEntry?.status || "active"; + row.regions.map((region) => { + const status = run.regionStatus.get(region) || "active"; const payload = getCheckerPayload(row, status); - const url = getCheckerUrl(row); return fetch(url, { headers: { @@ -577,7 +491,7 @@ export const monitorServiceImpl: ServiceImpl = { }, method: "POST", body: JSON.stringify(payload), - signal: AbortSignal.timeout(getCheckerTimeout(row)), + signal: AbortSignal.timeout(timeout), }); }), ); diff --git a/apps/server/src/routes/rpc/handlers/monitor/validators.test.ts b/apps/server/src/routes/rpc/handlers/monitor/validators.test.ts new file mode 100644 index 00000000..90b56493 --- /dev/null +++ b/apps/server/src/routes/rpc/handlers/monitor/validators.test.ts @@ -0,0 +1,169 @@ +import { Periodicity, Region } from "@openstatus/proto/monitor/v1"; +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { MONITOR_DEFAULTS } from "./converters"; +import { + getCommonCreateInput, + getCommonUpdateInput, + toValidMethod, + toValidPeriodicity, + validateCommonMonitorFields, +} from "./validators"; + +describe("getCommonCreateInput", () => { + test("applies the documented defaults when nothing is supplied", () => { + const result = getCommonCreateInput({ name: "m" }); + + expect(result.timeout).toBe(MONITOR_DEFAULTS.timeout); + expect(result.retry).toBe(MONITOR_DEFAULTS.retry); + expect(result.active).toBe(MONITOR_DEFAULTS.active); + expect(result.public).toBe(MONITOR_DEFAULTS.public); + expect(result.description).toBe(MONITOR_DEFAULTS.description); + expect(result.degradedAfter).toBeUndefined(); + }); + + test("always yields a concrete regions array, never undefined", () => { + // Guard rail: `undefined` would hand `createMonitor` its plan-based + // random-region fallback, which the API has never done. + expect(getCommonCreateInput({ name: "m" }).regions).toEqual([]); + expect(getCommonCreateInput({ name: "m", regions: [] }).regions).toEqual( + [], + ); + }); + + test("converts supplied regions and periodicity", () => { + const result = getCommonCreateInput({ + name: "m", + regions: [Region.FLY_AMS, Region.FLY_IAD], + periodicity: Periodicity.PERIODICITY_10M, + }); + + expect(result.regions).toEqual(["ams", "iad"]); + expect(result.periodicity).toBe("10m"); + }); + + test("passes explicit values through", () => { + const result = getCommonCreateInput({ + name: "m", + timeout: BigInt(20_000), + degradedAt: BigInt(5_000), + retry: BigInt(1), + active: true, + public: true, + description: "desc", + }); + + expect(result.timeout).toBe(20_000); + expect(result.degradedAfter).toBe(5_000); + expect(result.retry).toBe(1); + expect(result.active).toBe(true); + expect(result.public).toBe(true); + expect(result.description).toBe("desc"); + }); +}); + +describe("getCommonUpdateInput", () => { + test("omits every field when nothing is supplied", () => { + expect(getCommonUpdateInput({})).toEqual({}); + }); + + test("treats proto zero-values as 'not supplied'", () => { + // These fields have no explicit presence in the proto, so the zero + // value is indistinguishable from omission and must not be written. + const result = getCommonUpdateInput({ + name: "", + periodicity: Periodicity.PERIODICITY_UNSPECIFIED, + timeout: BigInt(0), + retry: BigInt(0), + regions: [], + }); + + expect(result).toEqual({}); + }); + + test("applies explicit false / empty for fields with proto presence", () => { + // `active`, `public` and `description` are optional in the proto, so + // an explicit false/"" is a real edit and must survive. + const result = getCommonUpdateInput({ + active: false, + public: false, + description: "", + }); + + expect(result.active).toBe(false); + expect(result.public).toBe(false); + expect(result.description).toBe(""); + }); + + test("converts supplied regions and periodicity", () => { + const result = getCommonUpdateInput({ + regions: [Region.FLY_AMS], + periodicity: Periodicity.PERIODICITY_1M, + }); + + expect(result.regions).toEqual(["ams"]); + expect(result.periodicity).toBe("1m"); + }); + + test("carries degradedAt through as degradedAfter", () => { + expect(getCommonUpdateInput({ degradedAt: BigInt(7_500) })).toEqual({ + degradedAfter: 7_500, + }); + }); + + test("splits an otel config into endpoint and headers", () => { + const result = getCommonUpdateInput({ + openTelemetry: { + $typeName: "openstatus.monitor.v1.OpenTelemetryConfig", + endpoint: "https://otel.example.com", + headers: [], + }, + }); + + expect(result.otelEndpoint).toBe("https://otel.example.com"); + expect(result.otelHeaders).toBeUndefined(); + }); +}); + +describe("toValidPeriodicity / toValidMethod", () => { + test("accepts known values", () => { + expect(toValidPeriodicity("10m")).toBe("10m"); + expect(toValidMethod("post")).toBe("POST"); + }); + + test("falls back on unknown values", () => { + expect(toValidPeriodicity("weekly")).toBe("1m"); + expect(toValidPeriodicity(undefined)).toBe("1m"); + expect(toValidMethod("FROB")).toBe("GET"); + expect(toValidMethod(undefined)).toBe("GET"); + }); +}); + +describe("validateCommonMonitorFields", () => { + test("accepts valid and absent regions", () => { + expect(() => validateCommonMonitorFields({})).not.toThrow(); + expect(() => validateCommonMonitorFields({ regions: [] })).not.toThrow(); + expect(() => + validateCommonMonitorFields({ regions: [Region.FLY_AMS] }), + ).not.toThrow(); + }); + + test("drops an unspecified region instead of rejecting it", () => { + // Documents current behaviour, which is weaker than it looks: + // `regionsToStrings` filters unmapped enum values to "", so the + // "Invalid regions" error below can never fire on the RPC path — + // every proto Region that maps to a non-empty string is in + // AVAILABLE_REGIONS. An unknown region is silently dropped and the + // monitor is created with fewer regions than the caller asked for. + expect(() => + validateCommonMonitorFields({ regions: [Region.UNSPECIFIED] }), + ).not.toThrow(); + expect( + getCommonCreateInput({ + name: "m", + regions: [Region.UNSPECIFIED, Region.FLY_AMS], + }).regions, + ).toEqual(["ams"]); + }); +}); diff --git a/apps/server/src/routes/rpc/handlers/monitor/validators.ts b/apps/server/src/routes/rpc/handlers/monitor/validators.ts index 0d0e3ff9..ce876ab9 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/validators.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/validators.ts @@ -2,12 +2,12 @@ import { Code, ConnectError } from "@connectrpc/connect"; import { monitorPeriodicity } from "@openstatus/db/src/schema/constants"; import { monitorMethods } from "@openstatus/db/src/schema/monitors/constants"; import type { Periodicity, Region } from "@openstatus/proto/monitor/v1"; +import type { UpdateMonitorConfigInput } from "@openstatus/services/monitor"; import { MONITOR_DEFAULTS, - openTelemetryToDb, + protoOpenTelemetryToService, periodicityToString, - regionsToDbString, regionsToStrings, validateRegions, } from "./converters"; @@ -59,9 +59,11 @@ export function validateCommonMonitorFields(mon: { regions?: Region[] }): void { } /** - * Extract common database values for all monitor types. + * Extract the fields every monitor type shares, in the shape + * `createMonitor` takes. Defaults are applied here rather than left to + * the column defaults so the API contract stays explicit. */ -export function getCommonDbValues(mon: { +export function getCommonCreateInput(mon: { name: string; periodicity?: Periodicity; timeout?: bigint; @@ -71,14 +73,13 @@ export function getCommonDbValues(mon: { public?: boolean; regions?: Region[]; retry?: bigint; - openTelemetry?: Parameters[0]; + openTelemetry?: Parameters[0]; }) { - const otelConfig = openTelemetryToDb(mon.openTelemetry); + const otelConfig = protoOpenTelemetryToService(mon.openTelemetry); const periodicityStr = mon.periodicity ? periodicityToString(mon.periodicity) : undefined; - const regionStrings = mon.regions ? regionsToStrings(mon.regions) : []; return { name: mon.name, @@ -88,7 +89,10 @@ export function getCommonDbValues(mon: { active: mon.active ?? MONITOR_DEFAULTS.active, description: mon.description || MONITOR_DEFAULTS.description, public: mon.public ?? MONITOR_DEFAULTS.public, - regions: regionsToDbString(regionStrings), + // Always a concrete list (possibly empty) — passing `undefined` would + // hand the service its plan-based random-region fallback, which the + // API has never done. + regions: mon.regions ? regionsToStrings(mon.regions) : [], retry: mon.retry ? Number(mon.retry) : MONITOR_DEFAULTS.retry, otelEndpoint: otelConfig.otelEndpoint, otelHeaders: otelConfig.otelHeaders, @@ -96,11 +100,10 @@ export function getCommonDbValues(mon: { } /** - * Extract common database values for update operations. - * Only includes fields that are explicitly provided (not undefined). - * This enables partial updates where only specified fields are changed. + * Same, for partial updates: only fields the caller actually provided, + * so `updateMonitorConfig` leaves the rest untouched. */ -export function getCommonDbValuesForUpdate(mon: { +export function getCommonUpdateInput(mon: { name?: string; periodicity?: Periodicity; timeout?: bigint; @@ -110,9 +113,9 @@ export function getCommonDbValuesForUpdate(mon: { public?: boolean; regions?: Region[]; retry?: bigint; - openTelemetry?: Parameters[0]; -}) { - const result: Record = {}; + openTelemetry?: Parameters[0]; +}): Omit { + const result: Omit = {}; if (mon.name !== undefined && mon.name !== "") { result.name = mon.name; @@ -146,8 +149,7 @@ export function getCommonDbValuesForUpdate(mon: { } if (mon.regions !== undefined && mon.regions.length > 0) { - const regionStrings = regionsToStrings(mon.regions); - result.regions = regionsToDbString(regionStrings); + result.regions = regionsToStrings(mon.regions); } if (mon.retry !== undefined && mon.retry !== BigInt(0)) { @@ -155,7 +157,7 @@ export function getCommonDbValuesForUpdate(mon: { } if (mon.openTelemetry !== undefined) { - const otelConfig = openTelemetryToDb(mon.openTelemetry); + const otelConfig = protoOpenTelemetryToService(mon.openTelemetry); result.otelEndpoint = otelConfig.otelEndpoint; result.otelHeaders = otelConfig.otelHeaders; } diff --git a/apps/server/src/routes/rpc/handlers/status-page/errors.ts b/apps/server/src/routes/rpc/handlers/status-page/errors.ts index 02a5c9a0..05335302 100644 --- a/apps/server/src/routes/rpc/handlers/status-page/errors.ts +++ b/apps/server/src/routes/rpc/handlers/status-page/errors.ts @@ -153,31 +153,6 @@ export function pageComponentNotFoundError(componentId: string): ConnectError { ); } -/** - * Creates a "failed to create page component" error. - */ -export function pageComponentCreateFailedError(): ConnectError { - return createError( - "Failed to create page component", - Code.Internal, - ErrorReason.PAGE_COMPONENT_CREATE_FAILED, - ); -} - -/** - * Creates a "failed to update page component" error. - */ -export function pageComponentUpdateFailedError( - componentId: string, -): ConnectError { - return createError( - "Failed to update page component", - Code.Internal, - ErrorReason.PAGE_COMPONENT_UPDATE_FAILED, - { "component-id": componentId }, - ); -} - /** * Creates a "component group not found" error. */ @@ -190,29 +165,6 @@ export function componentGroupNotFoundError(groupId: string): ConnectError { ); } -/** - * Creates a "failed to create component group" error. - */ -export function componentGroupCreateFailedError(): ConnectError { - return createError( - "Failed to create component group", - Code.Internal, - ErrorReason.COMPONENT_GROUP_CREATE_FAILED, - ); -} - -/** - * Creates a "failed to update component group" error. - */ -export function componentGroupUpdateFailedError(groupId: string): ConnectError { - return createError( - "Failed to update component group", - Code.Internal, - ErrorReason.COMPONENT_GROUP_UPDATE_FAILED, - { "group-id": groupId }, - ); -} - /** * Creates a "monitor not found" error. */ 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 198e1eaa..e589d23d 100644 --- a/apps/server/src/routes/rpc/handlers/status-page/index.ts +++ b/apps/server/src/routes/rpc/handlers/status-page/index.ts @@ -9,7 +9,6 @@ import { inArray, isNull, lte, - sql, } from "@openstatus/db"; import { maintenance, @@ -59,11 +58,19 @@ import { updatePagePasswordProtection, } from "@openstatus/services/page"; import { + createPageComponent, deletePageComponent, getPageComponentDailySummary, + updatePageComponent, } from "@openstatus/services/page-component"; +import { + createPageComponentGroup, + deletePageComponentGroup, + updatePageComponentGroup, +} from "@openstatus/services/page-component-group"; import { createPageSubscriber, + unsubscribePageSubscriber, upsertSelfSignupSubscriber, } from "@openstatus/services/page-subscriber"; import { getChannel } from "@openstatus/subscriptions"; @@ -94,16 +101,12 @@ import { } from "./converters"; import { authEmailDomainsRequiredError, - componentGroupCreateFailedError, componentGroupNotFoundError, - componentGroupUpdateFailedError, identifierRequiredError, invalidCustomDomainError, invalidIconUrlError, monitorNotFoundError, - pageComponentCreateFailedError, pageComponentNotFoundError, - pageComponentUpdateFailedError, passwordRequiredError, slugAlreadyExistsError, statusPageAccessDeniedError, @@ -1050,29 +1053,24 @@ export const statusPageServiceImpl: ServiceImpl = { await getGroupForPage(req.groupId, workspaceId, pageData.id); } - // Create the component - const newComponent = await db - .insert(pageComponent) - .values({ - workspaceId, - pageId: pageData.id, - type: "monitor", - monitorId: monitorData.id, - name: req.name ?? monitorData.name, - description: req.description ?? null, - order: req.order ?? 0, - groupId: req.groupId ? Number(req.groupId) : null, - }) - .returning() - .get(); + try { + const created = await createPageComponent({ + ctx: toServiceCtx(rpcCtx), + input: { + pageId: pageData.id, + type: "monitor", + monitorId: monitorData.id, + name: req.name ?? monitorData.name, + description: req.description ?? null, + order: req.order ?? 0, + groupId: req.groupId ? Number(req.groupId) : null, + }, + }); - if (!newComponent) { - throw pageComponentCreateFailedError(); + return { component: dbComponentToProto(created) }; + } catch (err) { + toConnectError(err); } - - return { - component: dbComponentToProto(newComponent), - }; }, async addStaticComponent(req, ctx) { @@ -1094,29 +1092,24 @@ export const statusPageServiceImpl: ServiceImpl = { await getGroupForPage(req.groupId, workspaceId, pageData.id); } - // Create the component - const newComponent = await db - .insert(pageComponent) - .values({ - workspaceId, - pageId: pageData.id, - type: "static", - monitorId: null, - name: req.name, - description: req.description ?? null, - order: req.order ?? 0, - groupId: req.groupId ? Number(req.groupId) : null, - }) - .returning() - .get(); + try { + const created = await createPageComponent({ + ctx: toServiceCtx(rpcCtx), + input: { + pageId: pageData.id, + type: "static", + monitorId: null, + name: req.name, + description: req.description ?? null, + order: req.order ?? 0, + groupId: req.groupId ? Number(req.groupId) : null, + }, + }); - if (!newComponent) { - throw pageComponentCreateFailedError(); + return { component: dbComponentToProto(created) }; + } catch (err) { + toConnectError(err); } - - return { - component: dbComponentToProto(newComponent), - }; }, async removeComponent(req, ctx) { @@ -1159,42 +1152,31 @@ export const statusPageServiceImpl: ServiceImpl = { await getGroupForPage(req.groupId, workspaceId, component.pageId); } - // Build update values - const updateValues: Record = { - updatedAt: new Date(), - }; - - if (req.name !== undefined && req.name !== "") { - updateValues.name = req.name; - } - if (req.description !== undefined) { - updateValues.description = req.description || null; - } - if (req.order !== undefined) { - updateValues.order = req.order; - } - if (req.groupId !== undefined) { - // Empty string means remove from group - updateValues.groupId = req.groupId === "" ? null : Number(req.groupId); - } - if (req.groupOrder !== undefined) { - updateValues.groupOrder = req.groupOrder; - } - - const updatedComponent = await db - .update(pageComponent) - .set(updateValues) - .where(eq(pageComponent.id, component.id)) - .returning() - .get(); + try { + const updated = await updatePageComponent({ + ctx: toServiceCtx(rpcCtx), + input: { + id: component.id, + name: + req.name !== undefined && req.name !== "" ? req.name : undefined, + description: + req.description !== undefined ? req.description || null : undefined, + order: req.order, + // Empty string means remove from group + groupId: + req.groupId !== undefined + ? req.groupId === "" + ? null + : Number(req.groupId) + : undefined, + groupOrder: req.groupOrder, + }, + }); - if (!updatedComponent) { - throw pageComponentUpdateFailedError(req.id); + return { component: dbComponentToProto(updated) }; + } catch (err) { + toConnectError(err); } - - return { - component: dbComponentToProto(updatedComponent), - }; }, async getPageComponent(req, ctx) { @@ -1226,25 +1208,20 @@ export const statusPageServiceImpl: ServiceImpl = { throw statusPageNotFoundError(req.pageId); } - // Create the group - const newGroup = await db - .insert(pageComponentGroup) - .values({ - workspaceId, - pageId: pageData.id, - name: req.name, - defaultOpen: req.defaultOpen ?? false, - }) - .returning() - .get(); + try { + const created = await createPageComponentGroup({ + ctx: toServiceCtx(rpcCtx), + input: { + pageId: pageData.id, + name: req.name, + defaultOpen: req.defaultOpen ?? false, + }, + }); - if (!newGroup) { - throw componentGroupCreateFailedError(); + return { group: dbGroupToProto(created) }; + } catch (err) { + toConnectError(err); } - - return { - group: dbGroupToProto(newGroup), - }; }, async deleteComponentGroup(req, ctx) { @@ -1261,10 +1238,14 @@ export const statusPageServiceImpl: ServiceImpl = { throw componentGroupNotFoundError(id); } - // Delete the group (components will have groupId set to null due to FK constraint) - await db - .delete(pageComponentGroup) - .where(eq(pageComponentGroup.id, group.id)); + try { + await deletePageComponentGroup({ + ctx: toServiceCtx(rpcCtx), + input: { id: group.id }, + }); + } catch (err) { + toConnectError(err); + } return { success: true }; }, @@ -1283,33 +1264,21 @@ export const statusPageServiceImpl: ServiceImpl = { throw componentGroupNotFoundError(id); } - // Build update values - const updateValues: Record = { - updatedAt: new Date(), - }; - - if (req.name !== undefined && req.name !== "") { - updateValues.name = req.name; - } - - if (req.defaultOpen !== undefined) { - updateValues.defaultOpen = req.defaultOpen; - } - - const updatedGroup = await db - .update(pageComponentGroup) - .set(updateValues) - .where(eq(pageComponentGroup.id, group.id)) - .returning() - .get(); + try { + const updated = await updatePageComponentGroup({ + ctx: toServiceCtx(rpcCtx), + input: { + id: group.id, + name: + req.name !== undefined && req.name !== "" ? req.name : undefined, + defaultOpen: req.defaultOpen, + }, + }); - if (!updatedGroup) { - throw componentGroupUpdateFailedError(req.id); + return { group: dbGroupToProto(updated) }; + } catch (err) { + toConnectError(err); } - - return { - group: dbGroupToProto(updatedGroup), - }; }, // ========================================================================== @@ -1453,58 +1422,28 @@ export const statusPageServiceImpl: ServiceImpl = { throw statusPageNotFoundError(req.pageId); } - // Find subscriber based on identifier type - if (req.identifier.case === "email") { - const subscriber = await db - .select() - .from(pageSubscriber) - .where( - and( - eq(pageSubscriber.pageId, pageData.id), - sql`LOWER(${pageSubscriber.email}) = ${req.identifier.value.toLowerCase()}`, - eq(pageSubscriber.channelType, "email"), - isNull(pageSubscriber.unsubscribedAt), - ), - ) - .get(); - - if (!subscriber) { - throw subscriberNotFoundError(req.identifier.value); - } - - await db - .update(pageSubscriber) - .set({ unsubscribedAt: new Date(), updatedAt: new Date() }) - .where(eq(pageSubscriber.id, subscriber.id)); - - return { success: true }; + if (req.identifier.case !== "email" && req.identifier.case !== "id") { + throw identifierRequiredError(); } - if (req.identifier.case === "id") { - const subscriber = await db - .select() - .from(pageSubscriber) - .where( - and( - eq(pageSubscriber.pageId, pageData.id), - eq(pageSubscriber.id, Number(req.identifier.value)), - ), - ) - .get(); + const identifier = + req.identifier.case === "email" + ? { type: "email" as const, value: req.identifier.value } + : { type: "id" as const, value: Number(req.identifier.value) }; - if (!subscriber) { + try { + await unsubscribePageSubscriber({ + ctx: toServiceCtx(rpcCtx), + input: { pageId: pageData.id, identifier }, + }); + } catch (err) { + if (err instanceof NotFoundError) { throw subscriberNotFoundError(req.identifier.value); } - - await db - .update(pageSubscriber) - .set({ unsubscribedAt: new Date(), updatedAt: new Date() }) - .where(eq(pageSubscriber.id, subscriber.id)); - - return { success: true }; + toConnectError(err); } - throw identifierRequiredError(); + return { success: true }; }, async listSubscribers(req, ctx) { diff --git a/apps/server/src/routes/rpc/interceptors/auth.ts b/apps/server/src/routes/rpc/interceptors/auth.ts index 72171178..71f647e5 100644 --- a/apps/server/src/routes/rpc/interceptors/auth.ts +++ b/apps/server/src/routes/rpc/interceptors/auth.ts @@ -1,37 +1,10 @@ -import { - Code, - ConnectError, - type Interceptor, - createContextKey, -} from "@connectrpc/connect"; -import type { Scope, Workspace } from "@openstatus/db/src/schema"; +import { Code, ConnectError, type Interceptor } from "@connectrpc/connect"; import { nanoid } from "nanoid"; import { lookupWorkspace, validateKey } from "../../../libs/middlewares/auth"; +import { RPC_CONTEXT_KEY, type RpcContext } from "./context"; -/** - * RPC context containing workspace and request information. - * This is set by the auth interceptor and available to all handlers. - */ -export interface RpcContext { - workspace: Workspace; - requestId: string; - /** - * Resolved API key identity. `id` is the stable key identifier (audit - * `actor_id`); `createdById` is the openstatus user who created the - * key (`api_key.created_by_id`, audit `actor_user_id`). - * `scopes` carries the access-control scopes for the resolved key — - * `requireScope` reads them inside service write verbs. - */ - apiKey: { id: string; createdById?: number; scopes: Scope[] }; -} - -/** - * Context key for storing RPC context in request context values. - */ -export const RPC_CONTEXT_KEY = createContextKey( - undefined, -); +export { RPC_CONTEXT_KEY, type RpcContext, getRpcContext } from "./context"; /** * Authentication interceptor for ConnectRPC. @@ -97,19 +70,3 @@ export function authInterceptor(): Interceptor { return next(req); }; } - -/** - * Helper to get RPC context from handler context. - */ -export function getRpcContext(ctx: { - values: { get: (key: { id: symbol; defaultValue: T }) => T }; -}): RpcContext { - const rpcCtx = ctx.values.get(RPC_CONTEXT_KEY); - if (!rpcCtx) { - throw new ConnectError( - "RPC context not found - auth interceptor may not have run", - Code.Internal, - ); - } - return rpcCtx; -} diff --git a/apps/server/src/routes/rpc/interceptors/context.ts b/apps/server/src/routes/rpc/interceptors/context.ts new file mode 100644 index 00000000..f58f0b57 --- /dev/null +++ b/apps/server/src/routes/rpc/interceptors/context.ts @@ -0,0 +1,45 @@ +import { Code, ConnectError, createContextKey } from "@connectrpc/connect"; +import type { Scope, Workspace } from "@openstatus/db/src/schema"; + +/** + * RPC context containing workspace and request information. + * This is set by the auth interceptor and available to all handlers. + */ +export interface RpcContext { + workspace: Workspace; + requestId: string; + /** + * Resolved API key identity. `id` is the stable key identifier (audit + * `actor_id`); `createdById` is the openstatus user who created the + * key (`api_key.created_by_id`, audit `actor_user_id`). + * `scopes` carries the access-control scopes for the resolved key — + * `requireScope` reads them inside service write verbs. + */ + apiKey: { id: string; createdById?: number; scopes: Scope[] }; +} + +/** + * Context key for storing RPC context in request context values. + * + * Lives here rather than in `auth.ts` so interceptors that only need to + * read the context don't pull in the auth middleware's dependency chain. + */ +export const RPC_CONTEXT_KEY = createContextKey( + undefined, +); + +/** + * Helper to get RPC context from handler context. + */ +export function getRpcContext(ctx: { + values: { get: (key: { id: symbol; defaultValue: T }) => T }; +}): RpcContext { + const rpcCtx = ctx.values.get(RPC_CONTEXT_KEY); + if (!rpcCtx) { + throw new ConnectError( + "RPC context not found - auth interceptor may not have run", + Code.Internal, + ); + } + return rpcCtx; +} diff --git a/packages/services/package.json b/packages/services/package.json index 676c2683..66ec2400 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -37,6 +37,10 @@ "import": "./src/page-component/index.ts", "types": "./src/page-component/index.ts" }, + "./page-component-group": { + "import": "./src/page-component-group/index.ts", + "types": "./src/page-component-group/index.ts" + }, "./page": { "import": "./src/page/index.ts", "types": "./src/page/index.ts" diff --git a/packages/services/src/__tests__/limits.test.ts b/packages/services/src/__tests__/limits.test.ts new file mode 100644 index 00000000..b8c84952 --- /dev/null +++ b/packages/services/src/__tests__/limits.test.ts @@ -0,0 +1,227 @@ +import { eq } from "@openstatus/db"; +import { + monitor, + monitorRun, + notification, + page, + pageComponent, + workspace, +} from "@openstatus/db/src/schema"; +import { expect } from "@std/expect"; +import { beforeAll, describe, test } from "@std/testing/bdd"; + +import { + createWorkspaceFixture, + withTestTransaction, +} from "../../test/helpers"; +import type { DrizzleTx } from "../context"; +import { LimitExceededError } from "../errors"; +import { LIMIT_KEYS, type LimitKey, assertWithinLimit } from "../limits"; + +const TEST_PREFIX = "svc-limits-test"; + +let workspaceId: number; +let seq = 0; +const unique = () => `${TEST_PREFIX}-${workspaceId}-${seq++}`; + +beforeAll(async () => { + workspaceId = (await createWorkspaceFixture("team")).workspace.id; +}); + +/** Pin one limit for the workspace so a case controls its own ceiling. */ +async function setLimit(tx: DrizzleTx, limit: LimitKey, max: number) { + await tx + .update(workspace) + .set({ limits: JSON.stringify({ [limit]: max }) }) + .where(eq(workspace.id, workspaceId)); +} + +function within(limit: LimitKey, delta?: number) { + return (tx: DrizzleTx) => + assertWithinLimit({ tx, workspaceId, limit, delta }); +} + +async function addMonitor(tx: DrizzleTx, overrides: { deletedAt?: Date } = {}) { + return tx + .insert(monitor) + .values({ + workspaceId, + url: "https://example.openstatus.dev", + name: unique(), + ...overrides, + }) + .returning() + .get(); +} + +async function addPage(tx: DrizzleTx) { + return tx + .insert(page) + .values({ + workspaceId, + title: unique(), + description: "", + // `slug` is globally unique, not per-workspace. + slug: unique(), + customDomain: "", + }) + .returning() + .get(); +} + +async function addRun(tx: DrizzleTx, monitorId: number, createdAt: Date) { + await tx + .insert(monitorRun) + .values({ workspaceId, monitorId, runnedAt: new Date(), createdAt }); +} + +describe("countCurrent coverage", () => { + // The real guard is the `never` in countCurrent's default branch, which + // makes an uncounted LimitKey a type error. This is its runtime twin: it + // fails if a key is listed but its case never runs a query. + for (const limit of LIMIT_KEYS) { + test(`counts "${limit}" instead of throwing not-implemented`, async () => { + await withTestTransaction(async (tx) => { + await setLimit(tx, limit, 1_000_000); + await expect(within(limit)(tx)).resolves.toBeUndefined(); + }); + }); + } + + test("every LimitKey resolves to a numeric plan limit", async () => { + const { limits } = (await createWorkspaceFixture("free")).workspace; + for (const limit of LIMIT_KEYS) { + expect(typeof limits[limit]).toBe("number"); + } + }); +}); + +describe("assertWithinLimit boundaries", () => { + test("monitors: counts live rows, ignores soft-deleted ones", async () => { + await withTestTransaction(async (tx) => { + await setLimit(tx, "monitors", 2); + await addMonitor(tx); + await addMonitor(tx, { deletedAt: new Date() }); + + // 1 live + 1 tombstoned: the tombstone must not consume quota. + await expect(within("monitors")(tx)).resolves.toBeUndefined(); + + await addMonitor(tx); + await expect(within("monitors")(tx)).rejects.toBeInstanceOf( + LimitExceededError, + ); + }); + }); + + test("status-pages: trips once the workspace is full", async () => { + await withTestTransaction(async (tx) => { + await setLimit(tx, "status-pages", 1); + await expect(within("status-pages")(tx)).resolves.toBeUndefined(); + + await addPage(tx); + await expect(within("status-pages")(tx)).rejects.toBeInstanceOf( + LimitExceededError, + ); + }); + }); + + test("page-components: counts across every page in the workspace", async () => { + await withTestTransaction(async (tx) => { + await setLimit(tx, "page-components", 2); + const pageA = await addPage(tx); + const pageB = await addPage(tx); + const values = (pageId: number) => ({ + workspaceId, + pageId, + type: "static" as const, + name: unique(), + order: 0, + }); + + await tx.insert(pageComponent).values(values(pageA.id)); + await expect(within("page-components")(tx)).resolves.toBeUndefined(); + + // The second component sits on a different page — a per-page count + // would still see room here. + await tx.insert(pageComponent).values(values(pageB.id)); + await expect(within("page-components")(tx)).rejects.toBeInstanceOf( + LimitExceededError, + ); + }); + }); + + test("notification-channels: trips once the workspace is full", async () => { + await withTestTransaction(async (tx) => { + await setLimit(tx, "notification-channels", 1); + await expect( + within("notification-channels")(tx), + ).resolves.toBeUndefined(); + + await tx.insert(notification).values({ + workspaceId, + name: unique(), + provider: "email", + data: JSON.stringify({ email: "limits@openstatus.dev" }), + }); + await expect(within("notification-channels")(tx)).rejects.toBeInstanceOf( + LimitExceededError, + ); + }); + }); + + test("synthetic-checks: meters the rolling month, not all history", async () => { + await withTestTransaction(async (tx) => { + await setLimit(tx, "synthetic-checks", 1); + const mon = await addMonitor(tx); + const stale = new Date(); + stale.setMonth(stale.getMonth() - 2); + + // Older than the window: spent quota, but not this month's. + await addRun(tx, mon.id, stale); + await expect(within("synthetic-checks")(tx)).resolves.toBeUndefined(); + + await addRun(tx, mon.id, new Date()); + await expect(within("synthetic-checks")(tx)).rejects.toBeInstanceOf( + LimitExceededError, + ); + }); + }); + + test("delta reserves more than one slot at a time", async () => { + await withTestTransaction(async (tx) => { + await setLimit(tx, "monitors", 3); + await addMonitor(tx); + + await expect(within("monitors", 2)(tx)).resolves.toBeUndefined(); + await expect(within("monitors", 3)(tx)).rejects.toBeInstanceOf( + LimitExceededError, + ); + }); + }); + + test("the error carries both the ceiling and the current usage", async () => { + await withTestTransaction(async (tx) => { + await setLimit(tx, "monitors", 1); + await addMonitor(tx); + + const err = await within("monitors")(tx).catch((e) => e); + expect(err).toBeInstanceOf(LimitExceededError); + expect(err.max).toBe(1); + expect(err.current).toBe(1); + }); + }); + + test("reads limits from the db, not a stale caller snapshot", async () => { + await withTestTransaction(async (tx) => { + await setLimit(tx, "monitors", 1); + await addMonitor(tx); + await expect(within("monitors")(tx)).rejects.toBeInstanceOf( + LimitExceededError, + ); + + // An add-on purchased mid-request lands as a workspace-row override. + await setLimit(tx, "monitors", 5); + await expect(within("monitors")(tx)).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/services/src/errors.ts b/packages/services/src/errors.ts index 3f560230..e50454d8 100644 --- a/packages/services/src/errors.ts +++ b/packages/services/src/errors.ts @@ -56,7 +56,12 @@ export class ValidationError extends ServiceError { } export class LimitExceededError extends ServiceError { - constructor(limit: string, max: number) { + constructor( + limit: string, + public max: number, + /** Actual usage when the caller counted it — surfaced in client error metadata. */ + public current?: number, + ) { super("LIMIT_EXCEEDED", `${limit} limit reached (${max})`); } } diff --git a/packages/services/src/index.ts b/packages/services/src/index.ts index 7b10554f..83914075 100644 --- a/packages/services/src/index.ts +++ b/packages/services/src/index.ts @@ -44,6 +44,11 @@ export { export { matchesScope, requireScope } from "./auth"; -export { assertWithinLimit, getPlanLimits, type LimitKey } from "./limits"; +export { + LIMIT_KEYS, + assertWithinLimit, + getPlanLimits, + type LimitKey, +} from "./limits"; export * from "./types"; diff --git a/packages/services/src/limits.ts b/packages/services/src/limits.ts index 35663ea3..d583afa3 100644 --- a/packages/services/src/limits.ts +++ b/packages/services/src/limits.ts @@ -1,5 +1,10 @@ -import { eq } from "@openstatus/db"; +import { and, count, eq, gte, isNull } from "@openstatus/db"; import { + monitor, + monitorRun, + notification, + page, + pageComponent, selectWorkspaceSchema, workspace as workspaceTable, } from "@openstatus/db/src/schema"; @@ -17,29 +22,135 @@ export const getPlanLimits = getLimits; // Count-style limits — the subset of Plan Limits that represent numeric row // counts enforceable via COUNT(*) + delta. Heterogeneous fields (booleans, // arrays, "Unlimited"|number) are intentionally excluded. -export type LimitKey = - | "monitors" - | "status-pages" - | "page-components" - | "notification-channels" - | "synthetic-checks"; +export const LIMIT_KEYS = [ + "monitors", + "status-pages", + "page-components", + "notification-channels", + "synthetic-checks", +] as const; + +export type LimitKey = (typeof LIMIT_KEYS)[number]; + +/** + * Start of the rolling window `synthetic-checks` is metered over. Returns + * both forms from one call so they can't drift by the milliseconds between + * two `new Date()`s: drizzle converts the `Date` for `monitor_run.created_at` + * (a seconds-mode column), while raw SQL has to pass the epoch seconds itself. + */ +export function syntheticChecksWindowStart(): { date: Date; seconds: number } { + const date = new Date(); + date.setMonth(date.getMonth() - 1); + return { date, seconds: Math.floor(date.getTime() / 1000) }; +} + +/** Runs a workspace has spent since `since`. */ +export async function countSyntheticChecksSince( + tx: DB, + workspaceId: number, + since: Date, +): Promise { + const row = await tx + .select({ count: count() }) + .from(monitorRun) + .where( + and( + eq(monitorRun.workspaceId, workspaceId), + gte(monitorRun.createdAt, since), + ), + ) + .get(); + return row?.count ?? 0; +} + +/** + * Read one numeric cap straight from the workspace row, so a plan change or + * purchased add-on committed mid-request wins over the caller's snapshot. + */ +export async function getWorkspaceLimit( + tx: DB, + workspaceId: number, + limit: LimitKey, +): Promise { + const row = await tx + .select() + .from(workspaceTable) + .where(eq(workspaceTable.id, workspaceId)) + .get(); + if (!row) throw new NotFoundError("workspace", workspaceId); + + const max = selectWorkspaceSchema.parse(row).limits[limit]; + if (typeof max !== "number") { + throw new Error( + `assertWithinLimit: limit "${limit}" resolved to non-numeric value`, + ); + } + return max; +} /** * Count currently-used resources of `limit` kind for the given workspace. - * Cases are filled in per-domain migration PR. Unknown keys throw — the default - * branch is a drift signal: a new LimitKey was added without a counter. + * The `default` branch is unreachable — `never` makes a new `LimitKey` + * without a counter a compile error rather than a runtime surprise. */ async function countCurrent( - _tx: DB, - _workspaceId: number, + tx: DB, + workspaceId: number, limit: LimitKey, ): Promise { switch (limit) { - // Per-domain cases land in PR 1+ as each domain migrates. - default: + // Soft-deleted monitors don't consume quota; `active`/`status` are + // irrelevant to the cap. + case "monitors": { + const row = await tx + .select({ count: count() }) + .from(monitor) + .where( + and(eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt)), + ) + .get(); + return row?.count ?? 0; + } + case "status-pages": { + const row = await tx + .select({ count: count() }) + .from(page) + .where(eq(page.workspaceId, workspaceId)) + .get(); + return row?.count ?? 0; + } + // Workspace-wide, not per-page: the cap spans every page in the + // workspace (see `page-component/update-order`). + case "page-components": { + const row = await tx + .select({ count: count() }) + .from(pageComponent) + .where(eq(pageComponent.workspaceId, workspaceId)) + .get(); + return row?.count ?? 0; + } + case "notification-channels": { + const row = await tx + .select({ count: count() }) + .from(notification) + .where(eq(notification.workspaceId, workspaceId)) + .get(); + return row?.count ?? 0; + } + // Usage over a rolling month, not a row count: `monitor_run` is + // append-only, so runs of since-deleted monitors still count. + case "synthetic-checks": + return countSyntheticChecksSince( + tx, + workspaceId, + syntheticChecksWindowStart().date, + ); + default: { + const unhandled: never = limit; throw new Error( - `assertWithinLimit: counter for "${limit}" not implemented. Add a case in countCurrent in packages/services/src/limits.ts.`, + `assertWithinLimit: counter for "${unhandled}" not implemented. Add a case in countCurrent in packages/services/src/limits.ts.`, ); + } } } @@ -51,21 +162,9 @@ export async function assertWithinLimit(args: { }): Promise { const { tx, workspaceId, limit, delta = 1 } = args; - const row = await tx - .select() - .from(workspaceTable) - .where(eq(workspaceTable.id, workspaceId)) - .get(); - if (!row) throw new NotFoundError("workspace", workspaceId); - - const fresh = selectWorkspaceSchema.parse(row); - const max = fresh.limits[limit]; - if (typeof max !== "number") { - throw new Error( - `assertWithinLimit: limit "${limit}" resolved to non-numeric value`, - ); + const max = await getWorkspaceLimit(tx, workspaceId, limit); + const current = await countCurrent(tx, workspaceId, limit); + if (current + delta > max) { + throw new LimitExceededError(limit, max, current); } - - const count = await countCurrent(tx, workspaceId, limit); - if (count + delta > max) throw new LimitExceededError(limit, max); } diff --git a/packages/services/src/monitor/__tests__/monitor.test.ts b/packages/services/src/monitor/__tests__/monitor.test.ts index 44eaaa04..fb654dfc 100644 --- a/packages/services/src/monitor/__tests__/monitor.test.ts +++ b/packages/services/src/monitor/__tests__/monitor.test.ts @@ -1,6 +1,7 @@ import { and, db, eq, inArray, isNull } from "@openstatus/db"; import { monitor, + monitorRun, monitorTag, monitorTagsToMonitors, notification, @@ -13,6 +14,7 @@ import { expect } from "@std/expect"; import { afterAll, beforeAll, describe, test } from "@std/testing/bdd"; import { + clearAuditLog, expectAuditRow, makeApiKeyCtx, makeSystemCtx, @@ -21,13 +23,22 @@ import { withTestTransaction, } from "../../../test/helpers"; import type { DrizzleTx, ServiceContext } from "../../context"; -import { ForbiddenError, NotFoundError } from "../../errors"; +import { + ForbiddenError, + LimitExceededError, + NotFoundError, +} from "../../errors"; import { cloneMonitor } from "../clone"; import { createMonitor } from "../create"; import { deleteMonitor, deleteMonitors } from "../delete"; import { getMonitor, listMonitors } from "../list"; import { updateMonitorNotifiers, updateMonitorTags } from "../relations"; -import { bulkUpdateMonitors, updateMonitorGeneral } from "../update"; +import { triggerMonitorRun } from "../trigger"; +import { + bulkUpdateMonitors, + updateMonitorConfig, + updateMonitorGeneral, +} from "../update"; const TEST_PREFIX = "svc-monitor-test"; @@ -816,3 +827,536 @@ describe("bulkUpdateMonitors", () => { }); }); }); + +describe("updateMonitorConfig", () => { + test("rejects read-only actor", async () => { + await withTestTransaction(async (tx) => { + const readOnlyCtx = { + ...makeApiKeyCtx(teamCtx.workspace, { + keyId: "k-read", + userId: 1, + scopes: ["read"], + }), + db: tx, + }; + await expect( + updateMonitorConfig({ + ctx: readOnlyCtx, + input: { id: 999_999_999, name: "nope" }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); + + test("patches only supplied fields and leaves the rest intact", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const row = await createMonitor({ + ctx, + input: { + name: `${TEST_PREFIX}-cfg`, + jobType: "http", + url: "https://example.com", + method: "GET", + headers: [], + assertions: [], + active: false, + periodicity: "10m", + regions: ["ams"], + timeout: 30_000, + retry: 2, + description: "keep me", + }, + }); + + const updated = await updateMonitorConfig({ + ctx, + input: { id: row.id, name: `${TEST_PREFIX}-cfg-renamed`, retry: 5 }, + }); + + expect(updated.name).toBe(`${TEST_PREFIX}-cfg-renamed`); + expect(updated.retry).toBe(5); + // Untouched fields survive the patch. + expect(updated.url).toBe("https://example.com"); + expect(updated.periodicity).toBe("10m"); + expect(updated.timeout).toBe(30_000); + expect(updated.description).toBe("keep me"); + expect(updated.regions).toEqual(["ams"]); + }); + }); + + test("emits a single monitor.update audit row per call", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const row = await createMonitor({ + ctx, + input: { + name: `${TEST_PREFIX}-cfg-audit`, + jobType: "http", + url: "https://example.com", + method: "GET", + headers: [], + assertions: [], + active: false, + }, + }); + + await updateMonitorConfig({ + ctx, + input: { + id: row.id, + name: `${TEST_PREFIX}-cfg-audit-2`, + public: true, + timeout: 20_000, + followRedirects: false, + }, + }); + + const rows = await readAuditLog({ + workspaceId: teamCtx.workspace.id, + entityType: "monitor", + entityId: String(row.id), + db: tx, + }); + const updates = rows.filter((r) => r.action === "monitor.update"); + expect(updates).toHaveLength(1); + }); + }); + + test("throws NotFoundError for cross-workspace id", async () => { + await withTestTransaction(async (tx) => { + const row = await createMonitor({ + ctx: { ...teamCtx, db: tx }, + input: { + name: `${TEST_PREFIX}-cfg-cross`, + jobType: "http", + url: "https://example.com", + method: "GET", + headers: [], + assertions: [], + active: false, + }, + }); + + await expect( + updateMonitorConfig({ + ctx: { ...freeCtx, db: tx }, + input: { id: row.id, name: "nope" }, + }), + ).rejects.toBeInstanceOf(NotFoundError); + }); + }); +}); + +describe("triggerMonitorRun", () => { + test("rejects read-only actor before touching the DB", async () => { + await withTestTransaction(async (tx) => { + const readOnlyCtx = { + ...makeApiKeyCtx(teamCtx.workspace, { + keyId: "k-read", + userId: 1, + scopes: ["read"], + }), + db: tx, + }; + await expect( + triggerMonitorRun({ + ctx: readOnlyCtx, + input: { id: 999_999_999 }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); + + test("records a run and returns the monitor with region status", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const row = await createMonitor({ + ctx, + input: { + name: `${TEST_PREFIX}-trigger`, + jobType: "http", + url: "https://example.com", + method: "GET", + headers: [], + assertions: [], + active: true, + regions: ["ams"], + }, + }); + + const result = await triggerMonitorRun({ ctx, input: { id: row.id } }); + + expect(result.monitor.id).toBe(row.id); + expect(result.monitor.regions).toEqual(["ams"]); + expect(typeof result.runId).toBe("number"); + + const runs = await tx + .select() + .from(monitorRun) + .where(eq(monitorRun.monitorId, row.id)) + .all(); + expect(runs).toHaveLength(1); + }); + }); + + test("throws NotFoundError for cross-workspace id", async () => { + await withTestTransaction(async (tx) => { + const row = await createMonitor({ + ctx: { ...teamCtx, db: tx }, + input: { + name: `${TEST_PREFIX}-trigger-cross`, + jobType: "http", + url: "https://example.com", + method: "GET", + headers: [], + assertions: [], + active: true, + }, + }); + + await expect( + triggerMonitorRun({ + ctx: { ...freeCtx, db: tx }, + input: { id: row.id }, + }), + ).rejects.toBeInstanceOf(NotFoundError); + }); + }); +}); + +describe("createMonitor config fields", () => { + test("persists every field the public API can set", async () => { + await withTestTransaction(async (tx) => { + // These columns are only reachable through the API's create path; + // if the service silently drops one, the monitor runs with a + // default the caller never asked for. + const row = await createMonitor({ + ctx: { ...teamCtx, db: tx }, + input: { + name: `${TEST_PREFIX}-full`, + jobType: "http", + url: "https://example.com", + method: "POST", + headers: [{ key: "X-Api", value: "1" }], + assertions: [], + active: true, + periodicity: "5m", + regions: ["ams", "iad"], + description: "full config", + public: true, + timeout: 12_000, + degradedAfter: 3_000, + retry: 4, + followRedirects: false, + otelEndpoint: "https://otel.example.com", + otelHeaders: [{ key: "authorization", value: "Bearer x" }], + }, + }); + + expect(row.description).toBe("full config"); + expect(row.public).toBe(true); + expect(row.timeout).toBe(12_000); + expect(row.degradedAfter).toBe(3_000); + expect(row.retry).toBe(4); + expect(row.followRedirects).toBe(false); + expect(row.otelEndpoint).toBe("https://otel.example.com"); + expect(row.regions).toEqual(["ams", "iad"]); + expect(row.periodicity).toBe("5m"); + }); + }); + + test("omitted config fields fall through to the column defaults", async () => { + await withTestTransaction(async (tx) => { + const row = await createMonitor({ + ctx: { ...teamCtx, db: tx }, + input: { + name: `${TEST_PREFIX}-defaults`, + jobType: "http", + url: "https://example.com", + method: "GET", + headers: [], + assertions: [], + active: false, + }, + }); + + expect(row.timeout).toBe(45_000); + expect(row.retry).toBe(3); + expect(row.followRedirects).toBe(true); + expect(row.public).toBe(false); + expect(row.description).toBe(""); + expect(row.degradedAfter).toBe(null); + }); + }); + + test("an empty regions array is honoured, not replaced by plan defaults", async () => { + await withTestTransaction(async (tx) => { + // The API has never auto-assigned regions; `[]` must stay `[]`. + const row = await createMonitor({ + ctx: { ...teamCtx, db: tx }, + input: { + name: `${TEST_PREFIX}-no-regions`, + jobType: "http", + url: "https://example.com", + method: "GET", + headers: [], + assertions: [], + active: false, + regions: [], + }, + }); + + expect(row.regions).toEqual([]); + }); + }); +}); + +describe("updateMonitorConfig clearing + replacement", () => { + async function seed(tx: DrizzleTx, name: string) { + return createMonitor({ + ctx: { ...teamCtx, db: tx }, + input: { + name, + jobType: "http", + url: "https://example.com", + method: "GET", + headers: [], + assertions: [ + { version: "v1", type: "status", compare: "eq", target: 200 }, + ], + active: false, + degradedAfter: 5_000, + }, + }); + } + + test("degradedAfter: null clears the column", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const row = await seed(tx, `${TEST_PREFIX}-clear-degraded`); + expect(row.degradedAfter).toBe(5_000); + + const updated = await updateMonitorConfig({ + ctx, + input: { id: row.id, degradedAfter: null }, + }); + expect(updated.degradedAfter).toBe(null); + }); + }); + + test("degradedAfter omitted leaves the column untouched", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const row = await seed(tx, `${TEST_PREFIX}-keep-degraded`); + + const updated = await updateMonitorConfig({ + ctx, + input: { id: row.id, name: `${TEST_PREFIX}-keep-degraded-2` }, + }); + expect(updated.degradedAfter).toBe(5_000); + }); + }); + + // The proto declares 0–120_000 ms for `timeout`/`degraded_at`; the RPC API + // accepted that range before these verbs existed, so they must too. + test("accepts the proto's full 0–120_000 ms timeout range", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const row = await seed(tx, `${TEST_PREFIX}-wide-timeout`); + + const updated = await updateMonitorConfig({ + ctx, + input: { id: row.id, timeout: 120_000, degradedAfter: 90_000 }, + }); + expect(updated.timeout).toBe(120_000); + expect(updated.degradedAfter).toBe(90_000); + + const created = await createMonitor({ + ctx, + input: { + name: `${TEST_PREFIX}-wide-timeout-create`, + jobType: "http", + url: "https://example.com", + method: "GET", + headers: [], + assertions: [], + active: false, + timeout: 120_000, + degradedAfter: 90_000, + }, + }); + expect(created.timeout).toBe(120_000); + expect(created.degradedAfter).toBe(90_000); + }); + }); + + test("rejects a timeout above the proto's ceiling", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const row = await seed(tx, `${TEST_PREFIX}-over-timeout`); + + await expect( + updateMonitorConfig({ ctx, input: { id: row.id, timeout: 120_001 } }), + ).rejects.toThrow(); + }); + }); + + test("assertions replace wholesale rather than merging", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const row = await seed(tx, `${TEST_PREFIX}-replace-assertions`); + + const updated = await updateMonitorConfig({ + ctx, + input: { + id: row.id, + assertions: [ + { version: "v1", type: "status", compare: "eq", target: 204 }, + ], + }, + }); + + const stored = JSON.parse(updated.assertions ?? "[]"); + expect(stored).toHaveLength(1); + expect(stored[0]).toMatchObject({ target: 204 }); + }); + }); + + // Matches the pre-service RPC path, where an empty converter result was + // `undefined` and Drizzle skipped the column. Omission never clears. + test("assertions omitted leaves the stored list untouched", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const row = await seed(tx, `${TEST_PREFIX}-keep-assertions`); + + const updated = await updateMonitorConfig({ + ctx, + input: { id: row.id, name: `${TEST_PREFIX}-keep-assertions-2` }, + }); + + const stored = JSON.parse(updated.assertions ?? "[]"); + expect(stored).toHaveLength(1); + expect(stored[0]).toMatchObject({ target: 200 }); + }); + }); + + test("no-op update emits no audit row", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const row = await seed(tx, `${TEST_PREFIX}-noop`); + await clearAuditLog(teamCtx.workspace.id, { db: tx }); + + await updateMonitorConfig({ + ctx, + input: { id: row.id, name: `${TEST_PREFIX}-noop` }, + }); + + const rows = await readAuditLog({ + workspaceId: teamCtx.workspace.id, + entityType: "monitor", + entityId: String(row.id), + db: tx, + }); + expect(rows.filter((r) => r.action === "monitor.update")).toHaveLength(0); + }); + }); +}); + +describe("triggerMonitorRun quota", () => { + test("throws LimitExceededError once the monthly quota is spent", async () => { + await withTestTransaction(async (tx) => { + // Quota is the only thing standing between an API key and unbounded + // outbound probes, so prove the ceiling actually stops the run. + const limit = teamCtx.workspace.limits["synthetic-checks"]; + const ctx = { ...teamCtx, db: tx }; + const row = await createMonitor({ + ctx, + input: { + name: `${TEST_PREFIX}-quota`, + jobType: "http", + url: "https://example.com", + method: "GET", + headers: [], + assertions: [], + active: true, + regions: ["ams"], + }, + }); + + for (let i = 0; i < limit; i++) { + await tx.insert(monitorRun).values({ + monitorId: row.id, + workspaceId: teamCtx.workspace.id, + runnedAt: new Date(), + }); + } + + await expect( + triggerMonitorRun({ ctx, input: { id: row.id } }), + ).rejects.toBeInstanceOf(LimitExceededError); + }); + }); +}); + +describe("monitor count quota", () => { + const input = (name: string) => ({ + name, + jobType: "http" as const, + url: "https://example.com", + method: "GET" as const, + headers: [], + assertions: [], + active: true, + }); + + // Both suite workspaces are team-plan, so pin the cap on the row — + // `assertWithinLimit` re-reads it, and the tx rolls the override back. + const capAtOne = async (tx: DrizzleTx) => { + await tx + .update(workspace) + .set({ limits: JSON.stringify({ monitors: 1 }) }) + .where(eq(workspace.id, freeWorkspaceId)); + return { ...freeCtx, db: tx }; + }; + + test("createMonitor rejects once the plan's monitor cap is spent", async () => { + await withTestTransaction(async (tx) => { + const ctx = await capAtOne(tx); + await createMonitor({ ctx, input: input(`${TEST_PREFIX}-cap-1`) }); + + await expect( + createMonitor({ ctx, input: input(`${TEST_PREFIX}-cap-2`) }), + ).rejects.toBeInstanceOf(LimitExceededError); + }); + }); + + test("a soft-deleted monitor frees its slot", async () => { + await withTestTransaction(async (tx) => { + const ctx = await capAtOne(tx); + const first = await createMonitor({ + ctx, + input: input(`${TEST_PREFIX}-cap-reuse-1`), + }); + await deleteMonitor({ ctx, input: { id: first.id } }); + + await expect( + createMonitor({ ctx, input: input(`${TEST_PREFIX}-cap-reuse-2`) }), + ).resolves.toBeDefined(); + }); + }); + + test("cloneMonitor rejects once the cap is spent", async () => { + await withTestTransaction(async (tx) => { + const ctx = await capAtOne(tx); + const source = await createMonitor({ + ctx, + input: input(`${TEST_PREFIX}-clone-cap`), + }); + + await expect( + cloneMonitor({ ctx, input: { id: source.id } }), + ).rejects.toBeInstanceOf(LimitExceededError); + }); + }); +}); diff --git a/packages/services/src/monitor/clone.ts b/packages/services/src/monitor/clone.ts index 2b571561..2c7f90a0 100644 --- a/packages/services/src/monitor/clone.ts +++ b/packages/services/src/monitor/clone.ts @@ -3,9 +3,10 @@ import { monitor, selectMonitorSchema } from "@openstatus/db/src/schema"; import { emitAudit } from "../audit"; import { requireScope } from "../auth"; import { type ServiceContext, withTransaction } from "../context"; -import { InternalServiceError, LimitExceededError } from "../errors"; +import { InternalServiceError } from "../errors"; +import { assertWithinLimit } from "../limits"; import type { Monitor } from "../types"; -import { countMonitorsInWorkspace, getMonitorInWorkspace } from "./internal"; +import { getMonitorInWorkspace } from "./internal"; import { CloneMonitorInput } from "./schemas"; /** @@ -21,10 +22,11 @@ export async function cloneMonitor(args: { const input = CloneMonitorInput.parse(args.input); return withTransaction(ctx, async (tx) => { - const current = await countMonitorsInWorkspace(tx, ctx.workspace.id); - if (current >= ctx.workspace.limits.monitors) { - throw new LimitExceededError("monitors", ctx.workspace.limits.monitors); - } + await assertWithinLimit({ + tx, + workspaceId: ctx.workspace.id, + limit: "monitors", + }); const source = await getMonitorInWorkspace({ tx, diff --git a/packages/services/src/monitor/create.ts b/packages/services/src/monitor/create.ts index e7998220..7b734301 100644 --- a/packages/services/src/monitor/create.ts +++ b/packages/services/src/monitor/create.ts @@ -3,10 +3,9 @@ import { monitor, selectMonitorSchema } from "@openstatus/db/src/schema"; import { emitAudit } from "../audit"; import { requireScope } from "../auth"; import { type ServiceContext, withTransaction } from "../context"; -import { LimitExceededError } from "../errors"; +import { assertWithinLimit } from "../limits"; import type { Monitor } from "../types"; import { - countMonitorsInWorkspace, headersToDbJson, pickDefaultRegions, serialiseAssertions, @@ -22,10 +21,11 @@ export async function createMonitor(args: { const input = CreateMonitorInput.parse(args.input); return withTransaction(ctx, async (tx) => { - const existing = await countMonitorsInWorkspace(tx, ctx.workspace.id); - if (existing >= ctx.workspace.limits.monitors) { - throw new LimitExceededError("monitors", ctx.workspace.limits.monitors); - } + await assertWithinLimit({ + tx, + workspaceId: ctx.workspace.id, + limit: "monitors", + }); const defaults = pickDefaultRegions(ctx.workspace); const regions = input.regions ?? defaults.regions; @@ -45,6 +45,14 @@ export async function createMonitor(args: { periodicity, regions: regions.join(","), assertions: serialiseAssertions(input.assertions), + description: input.description, + public: input.public, + timeout: input.timeout, + degradedAfter: input.degradedAfter, + retry: input.retry, + followRedirects: input.followRedirects, + otelEndpoint: input.otelEndpoint, + otelHeaders: headersToDbJson(input.otelHeaders), updatedAt: new Date(), }) .returning() diff --git a/packages/services/src/monitor/index.ts b/packages/services/src/monitor/index.ts index 23903f38..699f9d1b 100644 --- a/packages/services/src/monitor/index.ts +++ b/packages/services/src/monitor/index.ts @@ -38,8 +38,10 @@ export { StreamMonitorPreviewInput, streamMonitorPreview, } from "./stream-monitor-preview"; +export { triggerMonitorRun, type TriggerMonitorResult } from "./trigger"; export { bulkUpdateMonitors, + updateMonitorConfig, updateMonitorFollowRedirects, updateMonitorGeneral, updateMonitorOtel, @@ -66,6 +68,8 @@ export { monitorPeriodicity, type MonitorTimeRange, monitorTimeRange, + TriggerMonitorInput, + UpdateMonitorConfigInput, UpdateMonitorFollowRedirectsInput, UpdateMonitorGeneralInput, UpdateMonitorNotifiersInput, diff --git a/packages/services/src/monitor/internal.ts b/packages/services/src/monitor/internal.ts index 6c7b069e..e751cd59 100644 --- a/packages/services/src/monitor/internal.ts +++ b/packages/services/src/monitor/internal.ts @@ -7,7 +7,7 @@ import { TextBodyAssertion, serialize, } from "@openstatus/assertions"; -import { and, count, eq, inArray, isNull } from "@openstatus/db"; +import { and, eq, inArray, isNull } from "@openstatus/db"; import { monitor, monitorTag, @@ -46,19 +46,6 @@ export async function getMonitorInWorkspace(args: { return row; } -/** Count active (not soft-deleted) monitors in the workspace. */ -export async function countMonitorsInWorkspace( - tx: DB, - workspaceId: number, -): Promise { - const res = await tx - .select({ count: count() }) - .from(monitor) - .where(and(eq(monitor.workspaceId, workspaceId), isNull(monitor.deletedAt))) - .get(); - return res?.count ?? 0; -} - /** Validate that tag ids exist and belong to the workspace. */ export async function validateTagIds(args: { tx: DB; diff --git a/packages/services/src/monitor/schemas.ts b/packages/services/src/monitor/schemas.ts index fae7c043..c95f7bd0 100644 --- a/packages/services/src/monitor/schemas.ts +++ b/packages/services/src/monitor/schemas.ts @@ -23,6 +23,14 @@ const assertion = z.discriminatedUnion("type", [ recordAssertion, ]); +// Bounds mirror `insertMonitorSchema` (0–60_000 ms) — what the dashboard +// forms accept. +const timeoutMs = z.coerce.number().gte(0).lte(60_000); + +// The RPC API's proto contract declares 0–120_000 ms and validates it there, +// so the whole-object verbs must not reject a value the proto accepts. +const apiTimeoutMs = z.coerce.number().gte(0).lte(120_000); + /** * Create a new monitor. Regions and periodicity are optional — when unset, * the service picks sensible plan-based defaults (4 free regions / 6 paid @@ -39,9 +47,51 @@ export const CreateMonitorInput = z.object({ active: z.boolean().default(false), periodicity: z.enum(monitorPeriodicity).optional(), regions: z.array(z.string()).optional(), + // Config fields below are omitted by the dashboard create form and set + // by the public API. Each stays `undefined` when unset so the insert + // falls through to the column default rather than overwriting it. + description: z.string().optional(), + public: z.boolean().optional(), + timeout: apiTimeoutMs.optional(), + degradedAfter: apiTimeoutMs.nullish(), + retry: z.number().int().min(0).optional(), + followRedirects: z.boolean().optional(), + otelEndpoint: z.string().optional(), + otelHeaders: z.array(headerPair).optional(), }); export type CreateMonitorInput = z.infer; +/** + * Whole-object monitor patch backing the public API's update surface. + * Every field is optional and `undefined` means "leave as-is", so one + * request produces one UPDATE and one audit row — the granular verbs + * below stay for the dashboard's per-section forms. + * + * `jobType` is absent by design: callers address a monitor through a + * type-specific method that has already asserted the stored type. + */ +export const UpdateMonitorConfigInput = z.object({ + id: z.number().int(), + name: z.string().min(1).optional(), + url: z.string().optional(), + method: z.enum(monitorMethods).optional(), + headers: z.array(headerPair).optional(), + body: z.string().optional(), + assertions: z.array(assertion).optional(), + active: z.boolean().optional(), + periodicity: z.enum(monitorPeriodicity).optional(), + regions: z.array(z.string()).optional(), + description: z.string().optional(), + public: z.boolean().optional(), + timeout: apiTimeoutMs.optional(), + degradedAfter: apiTimeoutMs.nullish(), + retry: z.number().int().min(0).optional(), + followRedirects: z.boolean().optional(), + otelEndpoint: z.string().optional(), + otelHeaders: z.array(headerPair).optional(), +}); +export type UpdateMonitorConfigInput = z.infer; + /** Update the "general" monitor payload — name / endpoint / headers / assertions. */ export const UpdateMonitorGeneralInput = z.object({ id: z.number().int(), @@ -85,12 +135,10 @@ export const UpdateMonitorPublicInput = z.object({ }); export type UpdateMonitorPublicInput = z.infer; -// Bounds mirror `insertMonitorSchema` (0–60_000 ms) — the persisted checker -// timeout is hard-capped at 60 s and rejects negatives. export const UpdateMonitorResponseTimeInput = z.object({ id: z.number().int(), - timeout: z.coerce.number().gte(0).lte(60_000), - degradedAfter: z.coerce.number().gte(0).lte(60_000).nullish(), + timeout: timeoutMs, + degradedAfter: timeoutMs.nullish(), }); export type UpdateMonitorResponseTimeInput = z.infer< typeof UpdateMonitorResponseTimeInput @@ -136,6 +184,9 @@ export const DeleteMonitorsInput = z.object({ }); export type DeleteMonitorsInput = z.infer; +export const TriggerMonitorInput = z.object({ id: z.number().int() }); +export type TriggerMonitorInput = z.infer; + export const CloneMonitorInput = z.object({ id: z.number().int() }); export type CloneMonitorInput = z.infer; diff --git a/packages/services/src/monitor/trigger.ts b/packages/services/src/monitor/trigger.ts new file mode 100644 index 00000000..2f217f8c --- /dev/null +++ b/packages/services/src/monitor/trigger.ts @@ -0,0 +1,110 @@ +import { eq, sql } from "@openstatus/db"; +import { + monitorStatusTable, + selectMonitorSchema, +} from "@openstatus/db/src/schema"; +import { selectMonitorStatusSchema } from "@openstatus/db/src/schema/monitor_status/validation"; +import { z } from "zod"; + +import { requireScope } from "../auth"; +import { type ServiceContext, getReadDb } from "../context"; +import { LimitExceededError, ValidationError } from "../errors"; +import { + countSyntheticChecksSince, + getWorkspaceLimit, + syntheticChecksWindowStart, +} from "../limits"; +import type { Monitor } from "../types"; +import { getMonitorInWorkspace } from "./internal"; +import { TriggerMonitorInput } from "./schemas"; + +type MonitorRegionStatus = (typeof monitorStatusTable.$inferSelect)["status"]; + +export type TriggerMonitorResult = { + monitor: Monitor; + /** Per-region status, so callers can tell the checker what it's resuming from. */ + regionStatus: Map; + runId: number; +}; + +/** + * Record an on-demand run of a monitor and return everything a caller + * needs to dispatch the probes. The probes themselves are the caller's + * job — they need app-level checker config this package doesn't own. + * + * Callers must invoke this *before* dispatching, so a read-only key is + * rejected before any outbound request is made. + */ +export async function triggerMonitorRun(args: { + ctx: ServiceContext; + input: TriggerMonitorInput; +}): Promise { + const { ctx } = args; + requireScope(ctx, "write"); + const input = TriggerMonitorInput.parse(args.input); + const db = getReadDb(ctx); + + // One read of the cap for both the friendly pre-check and the reservation + // below — reading it twice could let them disagree mid-request. + const limit = await getWorkspaceLimit( + db, + ctx.workspace.id, + "synthetic-checks", + ); + const window = syntheticChecksWindowStart(); + const countUsed = () => + countSyntheticChecksSince(db, ctx.workspace.id, window.date); + + const used = await countUsed(); + if (used >= limit) { + throw new LimitExceededError("synthetic-checks", limit, used); + } + + const row = await getMonitorInWorkspace({ + tx: db, + id: input.id, + workspaceId: ctx.workspace.id, + }); + + const parsed = selectMonitorSchema.safeParse(row); + if (!parsed.success) { + throw new ValidationError(`Monitor ${input.id} has invalid data`); + } + + const statusRows = await db + .select() + .from(monitorStatusTable) + .where(eq(monitorStatusTable.monitorId, row.id)) + .all(); + + // Same guard the v1 trigger endpoint applies: a region/status outside the + // enum must not reach the checker payload. + const statuses = z.array(selectMonitorStatusSchema).safeParse(statusRows); + if (!statuses.success) { + throw new ValidationError(`Monitor ${input.id} has invalid region status`); + } + + // The count above only produces a good error message — it can't hold the + // ceiling, since concurrent triggers all read it before any insert lands. + // The reservation below re-counts inside the INSERT, which SQLite executes + // under the write lock, so exactly `limit` rows can ever be created. + const reserved = await db.get<{ id: number }>(sql` + INSERT INTO monitor_run (monitor_id, workspace_id, runned_at) + SELECT ${row.id}, ${row.workspaceId}, ${Date.now()} + WHERE ( + SELECT count(*) FROM monitor_run + WHERE workspace_id = ${ctx.workspace.id} AND created_at >= ${window.seconds} + ) < ${limit} + RETURNING id + `); + + if (!reserved) { + throw new LimitExceededError("synthetic-checks", limit, await countUsed()); + } + + return { + monitor: parsed.data, + regionStatus: new Map(statuses.data.map((s) => [s.region, s.status])), + runId: reserved.id, + }; +} diff --git a/packages/services/src/monitor/update.ts b/packages/services/src/monitor/update.ts index 52b97aff..63d07948 100644 --- a/packages/services/src/monitor/update.ts +++ b/packages/services/src/monitor/update.ts @@ -12,6 +12,7 @@ import { } from "./internal"; import { BulkUpdateMonitorsInput, + UpdateMonitorConfigInput, UpdateMonitorFollowRedirectsInput, UpdateMonitorGeneralInput, UpdateMonitorOtelInput, @@ -20,6 +21,75 @@ import { UpdateMonitorRetryInput, } from "./schemas"; +/** + * Apply a whole-object patch in a single UPDATE. `undefined` fields are + * left untouched; `degradedAfter: null` clears the column. + */ +export async function updateMonitorConfig(args: { + ctx: ServiceContext; + input: UpdateMonitorConfigInput; +}): Promise { + const { ctx } = args; + requireScope(ctx, "write"); + const input = UpdateMonitorConfigInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + const existing = await getMonitorInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + + const values: Record = { updatedAt: new Date() }; + if (input.name !== undefined) values.name = input.name; + if (input.url !== undefined) values.url = input.url; + if (input.method !== undefined) values.method = input.method; + if (input.headers !== undefined) { + values.headers = headersToDbJson(input.headers); + } + if (input.body !== undefined) values.body = input.body; + if (input.assertions !== undefined) { + values.assertions = serialiseAssertions(input.assertions); + } + if (input.active !== undefined) values.active = input.active; + if (input.periodicity !== undefined) values.periodicity = input.periodicity; + if (input.regions !== undefined) values.regions = input.regions.join(","); + if (input.description !== undefined) values.description = input.description; + if (input.public !== undefined) values.public = input.public; + if (input.timeout !== undefined) values.timeout = input.timeout; + if (input.degradedAfter !== undefined) { + values.degradedAfter = input.degradedAfter; + } + if (input.retry !== undefined) values.retry = input.retry; + if (input.followRedirects !== undefined) { + values.followRedirects = input.followRedirects; + } + if (input.otelEndpoint !== undefined) { + values.otelEndpoint = input.otelEndpoint; + } + if (input.otelHeaders !== undefined) { + values.otelHeaders = headersToDbJson(input.otelHeaders); + } + + const updated = await tx + .update(monitor) + .set(values) + .where(eq(monitor.id, existing.id)) + .returning() + .get(); + + await emitAudit(tx, ctx, { + action: "monitor.update", + entityType: "monitor", + entityId: existing.id, + before: existing, + after: updated, + }); + + return selectMonitorSchema.parse(updated); + }); +} + /** * Update a monitor's "general" fields — name / endpoint / method / headers / * body / assertions / active. Mirrors the tRPC `updateGeneral` surface and diff --git a/packages/services/src/notification/__tests__/notification.test.ts b/packages/services/src/notification/__tests__/notification.test.ts index 0d677505..e986c730 100644 --- a/packages/services/src/notification/__tests__/notification.test.ts +++ b/packages/services/src/notification/__tests__/notification.test.ts @@ -435,3 +435,24 @@ describe("list / get", () => { }); }); }); + +describe("notification-channel count quota", () => { + const input = (name: string) => ({ + name, + provider: "email" as const, + data: { email: `${name}@openstatus.dev` }, + monitors: [], + }); + + test("createNotification rejects once the plan's channel cap is spent", async () => { + await withTestTransaction(async (tx) => { + // Free plan caps notification channels at 1. + const ctx = { ...freeCtx, db: tx }; + await createNotification({ ctx, input: input(`${TEST_PREFIX}-cap-1`) }); + + await expect( + createNotification({ ctx, input: input(`${TEST_PREFIX}-cap-2`) }), + ).rejects.toBeInstanceOf(LimitExceededError); + }); + }); +}); diff --git a/packages/services/src/notification/create.ts b/packages/services/src/notification/create.ts index ce91935c..e31e115c 100644 --- a/packages/services/src/notification/create.ts +++ b/packages/services/src/notification/create.ts @@ -1,4 +1,3 @@ -import { count, eq } from "@openstatus/db"; import { notification, notificationsToMonitors, @@ -8,7 +7,7 @@ import { import { emitAudit } from "../audit"; import { requireScope } from "../auth"; import { type ServiceContext, withTransaction } from "../context"; -import { LimitExceededError } from "../errors"; +import { assertWithinLimit } from "../limits"; import type { Notification } from "../types"; import { assertProviderAllowed, @@ -35,20 +34,11 @@ export async function createNotification(args: { }); // Plan gate on notification count. - const existing = await tx - .select({ count: count() }) - .from(notification) - .where(eq(notification.workspaceId, ctx.workspace.id)) - .get(); - if ( - existing && - existing.count >= ctx.workspace.limits["notification-channels"] - ) { - throw new LimitExceededError( - "notification-channels", - ctx.workspace.limits["notification-channels"], - ); - } + await assertWithinLimit({ + tx, + workspaceId: ctx.workspace.id, + limit: "notification-channels", + }); // Plan gate on provider (sms / pagerduty / opsgenie / …). assertProviderAllowed(ctx.workspace, input.provider); diff --git a/packages/services/src/page-component-group/__tests__/page-component-group.test.ts b/packages/services/src/page-component-group/__tests__/page-component-group.test.ts new file mode 100644 index 00000000..0eb1e637 --- /dev/null +++ b/packages/services/src/page-component-group/__tests__/page-component-group.test.ts @@ -0,0 +1,244 @@ +import { db, eq } from "@openstatus/db"; +import { page, pageComponentGroup } from "@openstatus/db/src/schema"; +import { expect } from "@std/expect"; +import { afterAll, beforeAll, describe, test } from "@std/testing/bdd"; + +import { + createWorkspaceFixture, + expectAuditRow, + makeApiKeyCtx, + makeUserCtx, + withTestTransaction, +} from "../../../test/helpers"; +import type { ServiceContext } from "../../context"; +import { ForbiddenError, NotFoundError } from "../../errors"; +import { createPageComponentGroup } from "../create"; +import { deletePageComponentGroup } from "../delete"; +import { updatePageComponentGroup } from "../update"; + +const TEST_PREFIX = "svc-page-component-group-test"; + +let teamCtx: ServiceContext; +let freeCtx: ServiceContext; +let testPageId: number; + +function readOnlyCtx(ctx: ServiceContext, tx: ServiceContext["db"]) { + return { + ...makeApiKeyCtx(ctx.workspace, { + keyId: "k-read", + userId: 1, + scopes: ["read"], + }), + db: tx, + }; +} + +beforeAll(async () => { + const team = (await createWorkspaceFixture("team")).workspace; + const free = (await createWorkspaceFixture("free")).workspace; + teamCtx = makeUserCtx(team, { userId: 1 }); + freeCtx = makeUserCtx(free, { userId: 2 }); + + const pageRow = await db + .insert(page) + .values({ + workspaceId: team.id, + title: `${TEST_PREFIX}-page`, + description: "test", + slug: `${TEST_PREFIX}-slug`, + customDomain: "", + }) + .returning() + .get(); + testPageId = pageRow.id; +}); + +afterAll(async () => { + await db + .delete(pageComponentGroup) + .where(eq(pageComponentGroup.pageId, testPageId)) + .catch(() => undefined); + await db + .delete(page) + .where(eq(page.id, testPageId)) + .catch(() => undefined); +}); + +describe("createPageComponentGroup", () => { + test("rejects read-only actor", async () => { + await withTestTransaction(async (tx) => { + await expect( + createPageComponentGroup({ + ctx: readOnlyCtx(teamCtx, tx), + input: { pageId: testPageId, name: "nope", defaultOpen: false }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); + + test("creates a group and emits an audit row", async () => { + await withTestTransaction(async (tx) => { + const created = await createPageComponentGroup({ + ctx: { ...teamCtx, db: tx }, + input: { + pageId: testPageId, + name: `${TEST_PREFIX}-group`, + defaultOpen: true, + }, + }); + + expect(created.name).toBe(`${TEST_PREFIX}-group`); + expect(created.defaultOpen).toBe(true); + await expectAuditRow({ + workspaceId: teamCtx.workspace.id, + action: "page_component_group.create", + entityType: "page_component_group", + entityId: created.id, + db: tx, + }); + }); + }); + + test("rejects a page from another workspace", async () => { + await withTestTransaction(async (tx) => { + await expect( + createPageComponentGroup({ + ctx: { ...freeCtx, db: tx }, + input: { + pageId: testPageId, + name: `${TEST_PREFIX}-cross`, + defaultOpen: false, + }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); +}); + +describe("updatePageComponentGroup", () => { + test("rejects read-only actor", async () => { + await withTestTransaction(async (tx) => { + await expect( + updatePageComponentGroup({ + ctx: readOnlyCtx(teamCtx, tx), + input: { id: 999_999_999, name: "nope" }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); + + test("patches only the supplied fields and emits an audit row", async () => { + await withTestTransaction(async (tx) => { + const teamCtxTx = { ...teamCtx, db: tx }; + const created = await createPageComponentGroup({ + ctx: teamCtxTx, + input: { + pageId: testPageId, + name: `${TEST_PREFIX}-before`, + defaultOpen: true, + }, + }); + + const updated = await updatePageComponentGroup({ + ctx: teamCtxTx, + input: { id: created.id, name: `${TEST_PREFIX}-after` }, + }); + + expect(updated.name).toBe(`${TEST_PREFIX}-after`); + expect(updated.defaultOpen).toBe(true); + await expectAuditRow({ + workspaceId: teamCtx.workspace.id, + action: "page_component_group.update", + entityType: "page_component_group", + entityId: created.id, + db: tx, + }); + }); + }); + + test("throws NotFoundError for cross-workspace id", async () => { + await withTestTransaction(async (tx) => { + const created = await createPageComponentGroup({ + ctx: { ...teamCtx, db: tx }, + input: { + pageId: testPageId, + name: `${TEST_PREFIX}-cross-update`, + defaultOpen: false, + }, + }); + + await expect( + updatePageComponentGroup({ + ctx: { ...freeCtx, db: tx }, + input: { id: created.id, name: "nope" }, + }), + ).rejects.toBeInstanceOf(NotFoundError); + }); + }); +}); + +describe("deletePageComponentGroup", () => { + test("rejects read-only actor", async () => { + await withTestTransaction(async (tx) => { + await expect( + deletePageComponentGroup({ + ctx: readOnlyCtx(teamCtx, tx), + input: { id: 999_999_999 }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); + + test("deletes the group and emits an audit row", async () => { + await withTestTransaction(async (tx) => { + const teamCtxTx = { ...teamCtx, db: tx }; + const created = await createPageComponentGroup({ + ctx: teamCtxTx, + input: { + pageId: testPageId, + name: `${TEST_PREFIX}-doomed`, + defaultOpen: false, + }, + }); + + await deletePageComponentGroup({ + ctx: teamCtxTx, + input: { id: created.id }, + }); + + const remaining = await tx + .select() + .from(pageComponentGroup) + .where(eq(pageComponentGroup.id, created.id)) + .get(); + expect(remaining).toBeUndefined(); + await expectAuditRow({ + workspaceId: teamCtx.workspace.id, + action: "page_component_group.delete", + entityType: "page_component_group", + entityId: created.id, + db: tx, + }); + }); + }); + + test("throws NotFoundError for cross-workspace id", async () => { + await withTestTransaction(async (tx) => { + const created = await createPageComponentGroup({ + ctx: { ...teamCtx, db: tx }, + input: { + pageId: testPageId, + name: `${TEST_PREFIX}-cross-delete`, + defaultOpen: false, + }, + }); + + await expect( + deletePageComponentGroup({ + ctx: { ...freeCtx, db: tx }, + input: { id: created.id }, + }), + ).rejects.toBeInstanceOf(NotFoundError); + }); + }); +}); diff --git a/packages/services/src/page-component-group/create.ts b/packages/services/src/page-component-group/create.ts new file mode 100644 index 00000000..a8d10342 --- /dev/null +++ b/packages/services/src/page-component-group/create.ts @@ -0,0 +1,44 @@ +import { pageComponentGroup } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { requireScope } from "../auth"; +import { type ServiceContext, withTransaction } from "../context"; +import { assertPageInWorkspace } from "../page-component/internal"; +import { CreatePageComponentGroupInput } from "./schemas"; + +export async function createPageComponentGroup(args: { + ctx: ServiceContext; + input: CreatePageComponentGroupInput; +}) { + const { ctx } = args; + requireScope(ctx, "write"); + const input = CreatePageComponentGroupInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + await assertPageInWorkspace({ + tx, + pageId: input.pageId, + workspaceId: ctx.workspace.id, + }); + + const created = await tx + .insert(pageComponentGroup) + .values({ + workspaceId: ctx.workspace.id, + pageId: input.pageId, + name: input.name, + defaultOpen: input.defaultOpen, + }) + .returning() + .get(); + + await emitAudit(tx, ctx, { + action: "page_component_group.create", + entityType: "page_component_group", + entityId: created.id, + after: created, + }); + + return created; + }); +} diff --git a/packages/services/src/page-component-group/delete.ts b/packages/services/src/page-component-group/delete.ts new file mode 100644 index 00000000..27ce2de0 --- /dev/null +++ b/packages/services/src/page-component-group/delete.ts @@ -0,0 +1,40 @@ +import { eq } from "@openstatus/db"; +import { pageComponentGroup } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { requireScope } from "../auth"; +import { type ServiceContext, withTransaction } from "../context"; +import { getGroupInWorkspace } from "./internal"; +import { DeletePageComponentGroupInput } from "./schemas"; + +/** + * Hard-delete a group. Member components survive with `group_id` reset to + * NULL (FK `ON DELETE SET NULL`) — they fall back to the ungrouped list. + */ +export async function deletePageComponentGroup(args: { + ctx: ServiceContext; + input: DeletePageComponentGroupInput; +}): Promise { + const { ctx } = args; + requireScope(ctx, "write"); + const input = DeletePageComponentGroupInput.parse(args.input); + + await withTransaction(ctx, async (tx) => { + const existing = await getGroupInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + + await tx + .delete(pageComponentGroup) + .where(eq(pageComponentGroup.id, existing.id)); + + await emitAudit(tx, ctx, { + action: "page_component_group.delete", + entityType: "page_component_group", + entityId: existing.id, + before: existing, + }); + }); +} diff --git a/packages/services/src/page-component-group/index.ts b/packages/services/src/page-component-group/index.ts new file mode 100644 index 00000000..286f3d50 --- /dev/null +++ b/packages/services/src/page-component-group/index.ts @@ -0,0 +1,9 @@ +export { createPageComponentGroup } from "./create"; +export { deletePageComponentGroup } from "./delete"; +export { updatePageComponentGroup } from "./update"; + +export { + CreatePageComponentGroupInput, + DeletePageComponentGroupInput, + UpdatePageComponentGroupInput, +} from "./schemas"; diff --git a/packages/services/src/page-component-group/internal.ts b/packages/services/src/page-component-group/internal.ts new file mode 100644 index 00000000..de99bbf6 --- /dev/null +++ b/packages/services/src/page-component-group/internal.ts @@ -0,0 +1,26 @@ +import { and, eq } from "@openstatus/db"; +import { pageComponentGroup } from "@openstatus/db/src/schema"; + +import type { DB } from "../context"; +import { NotFoundError } from "../errors"; + +/** Load a group by id, scoped to the workspace. */ +export async function getGroupInWorkspace(args: { + tx: DB; + id: number; + workspaceId: number; +}) { + const { tx, id, workspaceId } = args; + const row = await tx + .select() + .from(pageComponentGroup) + .where( + and( + eq(pageComponentGroup.id, id), + eq(pageComponentGroup.workspaceId, workspaceId), + ), + ) + .get(); + if (!row) throw new NotFoundError("page_component_group", id); + return row; +} diff --git a/packages/services/src/page-component-group/schemas.ts b/packages/services/src/page-component-group/schemas.ts new file mode 100644 index 00000000..c231ae2f --- /dev/null +++ b/packages/services/src/page-component-group/schemas.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +export const CreatePageComponentGroupInput = z.object({ + pageId: z.number().int(), + name: z.string().min(1), + defaultOpen: z.boolean().default(false), +}); +// `z.input`, not `z.infer` — the output type marks defaulted fields required, +// which would force callers to pass what the schema already defaults. +export type CreatePageComponentGroupInput = z.input< + typeof CreatePageComponentGroupInput +>; + +/** Partial patch — `undefined` leaves a field as-is. */ +export const UpdatePageComponentGroupInput = z.object({ + id: z.number().int(), + name: z.string().min(1).optional(), + defaultOpen: z.boolean().optional(), +}); +export type UpdatePageComponentGroupInput = z.infer< + typeof UpdatePageComponentGroupInput +>; + +export const DeletePageComponentGroupInput = z.object({ + id: z.number().int(), +}); +export type DeletePageComponentGroupInput = z.infer< + typeof DeletePageComponentGroupInput +>; diff --git a/packages/services/src/page-component-group/update.ts b/packages/services/src/page-component-group/update.ts new file mode 100644 index 00000000..1f715dae --- /dev/null +++ b/packages/services/src/page-component-group/update.ts @@ -0,0 +1,46 @@ +import { eq } from "@openstatus/db"; +import { pageComponentGroup } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { requireScope } from "../auth"; +import { type ServiceContext, withTransaction } from "../context"; +import { getGroupInWorkspace } from "./internal"; +import { UpdatePageComponentGroupInput } from "./schemas"; + +export async function updatePageComponentGroup(args: { + ctx: ServiceContext; + input: UpdatePageComponentGroupInput; +}) { + const { ctx } = args; + requireScope(ctx, "write"); + const input = UpdatePageComponentGroupInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + const existing = await getGroupInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + + const values: Record = { updatedAt: new Date() }; + if (input.name !== undefined) values.name = input.name; + if (input.defaultOpen !== undefined) values.defaultOpen = input.defaultOpen; + + const updated = await tx + .update(pageComponentGroup) + .set(values) + .where(eq(pageComponentGroup.id, existing.id)) + .returning() + .get(); + + await emitAudit(tx, ctx, { + action: "page_component_group.update", + entityType: "page_component_group", + entityId: existing.id, + before: existing, + after: updated, + }); + + return updated; + }); +} diff --git a/packages/services/src/page-component/__tests__/page-component.test.ts b/packages/services/src/page-component/__tests__/page-component.test.ts index 380d9235..3c24d041 100644 --- a/packages/services/src/page-component/__tests__/page-component.test.ts +++ b/packages/services/src/page-component/__tests__/page-component.test.ts @@ -4,6 +4,7 @@ import { page, pageComponent, pageComponentGroup, + workspace, } from "@openstatus/db/src/schema"; import { expect } from "@std/expect"; import { afterAll, beforeAll, describe, test } from "@std/testing/bdd"; @@ -15,10 +16,17 @@ import { makeUserCtx, withTestTransaction, } from "../../../test/helpers"; -import type { ServiceContext } from "../../context"; -import { ForbiddenError, NotFoundError } from "../../errors"; +import type { DrizzleTx, ServiceContext } from "../../context"; +import { + ConflictError, + ForbiddenError, + LimitExceededError, + NotFoundError, +} from "../../errors"; +import { createPageComponent } from "../create"; import { deletePageComponent } from "../delete"; import { listPageComponents } from "../list"; +import { updatePageComponent } from "../update"; import { updatePageComponentOrder } from "../update-order"; const TEST_PREFIX = "svc-page-component-test"; @@ -364,3 +372,392 @@ describe("deletePageComponent", () => { }); }); }); + +describe("createPageComponent", () => { + test("rejects read-only actor", async () => { + await withTestTransaction(async (tx) => { + const readOnlyCtx = { + ...makeApiKeyCtx(teamCtx.workspace, { + keyId: "k-read", + userId: 1, + scopes: ["read"], + }), + db: tx, + }; + await expect( + createPageComponent({ + ctx: readOnlyCtx, + input: { + pageId: testPageId, + type: "static", + name: `${TEST_PREFIX}-denied`, + order: 0, + }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); + + test("creates a static component and emits an audit row", async () => { + await withTestTransaction(async (tx) => { + const created = await createPageComponent({ + ctx: { ...teamCtx, db: tx }, + input: { + pageId: testPageId, + type: "static", + name: `${TEST_PREFIX}-static`, + description: "hello", + order: 3, + }, + }); + + expect(created.type).toBe("static"); + expect(created.monitorId).toBe(null); + expect(created.name).toBe(`${TEST_PREFIX}-static`); + await expectAuditRow({ + workspaceId: teamCtx.workspace.id, + action: "page_component.create", + entityType: "page_component", + entityId: created.id, + db: tx, + }); + }); + }); + + test("inherits the monitor name when none is supplied", async () => { + await withTestTransaction(async (tx) => { + const created = await createPageComponent({ + ctx: { ...teamCtx, db: tx }, + input: { + pageId: testPageId, + type: "monitor", + monitorId: teamMonitorId, + order: 0, + }, + }); + + expect(created.name).toBe(`${TEST_PREFIX}-team-monitor`); + }); + }); + + test("rejects a monitor from another workspace", async () => { + await withTestTransaction(async (tx) => { + await expect( + createPageComponent({ + ctx: { ...teamCtx, db: tx }, + input: { + pageId: testPageId, + type: "monitor", + monitorId: freeMonitorId, + order: 0, + }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); + + test("rejects a page from another workspace", async () => { + await withTestTransaction(async (tx) => { + await expect( + createPageComponent({ + ctx: { ...freeCtx, db: tx }, + input: { + pageId: testPageId, + type: "static", + name: `${TEST_PREFIX}-cross`, + order: 0, + }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); + + test("rejects a monitor already attached to the page", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const input = { + pageId: testPageId, + type: "monitor" as const, + monitorId: teamMonitorId, + order: 0, + }; + + await createPageComponent({ ctx, input }); + await expect(createPageComponent({ ctx, input })).rejects.toBeInstanceOf( + ConflictError, + ); + }); + }); + + test("enforces the workspace-wide page-components limit", async () => { + await withTestTransaction(async (tx) => { + // The cap counts every page in the workspace, so seed the quota on a + // second page and prove the create on `testPageId` still trips it. + const otherPage = await tx + .insert(page) + .values({ + workspaceId: teamCtx.workspace.id, + title: `${TEST_PREFIX}-limit-page`, + description: "test", + slug: `${TEST_PREFIX}-limit-slug`, + customDomain: "", + }) + .returning() + .get(); + + // `assertWithinLimit` re-reads the workspace row, so the override has + // to land in the DB rather than only on the in-memory ctx. + await tx + .update(workspace) + .set({ limits: JSON.stringify({ "page-components": 1 }) }) + .where(eq(workspace.id, teamCtx.workspace.id)); + + const ctx = { ...teamCtx, db: tx }; + + await createPageComponent({ + ctx, + input: { + pageId: otherPage.id, + type: "static", + name: `${TEST_PREFIX}-limit-1`, + order: 0, + }, + }); + + await expect( + createPageComponent({ + ctx, + input: { + pageId: testPageId, + type: "static", + name: `${TEST_PREFIX}-limit-2`, + order: 0, + }, + }), + ).rejects.toBeInstanceOf(LimitExceededError); + }); + }); + + test("at quota, a bad request still reports what's actually wrong", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const input = { + pageId: testPageId, + type: "monitor" as const, + monitorId: teamMonitorId, + order: 0, + }; + await createPageComponent({ ctx, input }); + + // Quota is now spent, but neither of these would consume a slot — + // "limit reached" would send the caller chasing the wrong problem. + await tx + .update(workspace) + .set({ limits: JSON.stringify({ "page-components": 1 }) }) + .where(eq(workspace.id, teamCtx.workspace.id)); + + await expect(createPageComponent({ ctx, input })).rejects.toBeInstanceOf( + ConflictError, + ); + await expect( + createPageComponent({ + ctx, + input: { ...input, monitorId: freeMonitorId }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); +}); + +describe("updatePageComponent", () => { + test("rejects read-only actor", async () => { + await withTestTransaction(async (tx) => { + const readOnlyCtx = { + ...makeApiKeyCtx(teamCtx.workspace, { + keyId: "k-read", + userId: 1, + scopes: ["read"], + }), + db: tx, + }; + await expect( + updatePageComponent({ + ctx: readOnlyCtx, + input: { id: 999_999_999, name: "nope" }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + }); + + test("patches only the supplied fields and emits an audit row", async () => { + await withTestTransaction(async (tx) => { + const teamCtxTx = { ...teamCtx, db: tx }; + const created = await createPageComponent({ + ctx: teamCtxTx, + input: { + pageId: testPageId, + type: "static", + name: `${TEST_PREFIX}-before`, + description: "keep me", + order: 1, + }, + }); + + const updated = await updatePageComponent({ + ctx: teamCtxTx, + input: { id: created.id, name: `${TEST_PREFIX}-after` }, + }); + + expect(updated.name).toBe(`${TEST_PREFIX}-after`); + expect(updated.description).toBe("keep me"); + expect(updated.order).toBe(1); + await expectAuditRow({ + workspaceId: teamCtx.workspace.id, + action: "page_component.update", + entityType: "page_component", + entityId: created.id, + db: tx, + }); + }); + }); + + test("throws NotFoundError for cross-workspace id", async () => { + await withTestTransaction(async (tx) => { + const created = await createPageComponent({ + ctx: { ...teamCtx, db: tx }, + input: { + pageId: testPageId, + type: "static", + name: `${TEST_PREFIX}-cross-update`, + order: 0, + }, + }); + + await expect( + updatePageComponent({ + ctx: { ...freeCtx, db: tx }, + input: { id: created.id, name: "nope" }, + }), + ).rejects.toBeInstanceOf(NotFoundError); + }); + }); +}); + +describe("page component group scoping", () => { + async function makeGroup(tx: DrizzleTx, pageId: number, name: string) { + return tx + .insert(pageComponentGroup) + .values({ workspaceId: teamCtx.workspace.id, pageId, name }) + .returning() + .get(); + } + + async function makeSecondPage(tx: DrizzleTx) { + return tx + .insert(page) + .values({ + workspaceId: teamCtx.workspace.id, + title: `${TEST_PREFIX}-page-2`, + description: "test", + slug: `${TEST_PREFIX}-slug-2`, + customDomain: "", + }) + .returning() + .get(); + } + + test("createPageComponent accepts a group on the same page", async () => { + await withTestTransaction(async (tx) => { + const group = await makeGroup(tx, testPageId, `${TEST_PREFIX}-grp-ok`); + const created = await createPageComponent({ + ctx: { ...teamCtx, db: tx }, + input: { + pageId: testPageId, + type: "static", + name: `${TEST_PREFIX}-grouped`, + order: 0, + groupId: group.id, + }, + }); + expect(created.groupId).toBe(group.id); + }); + }); + + test("createPageComponent rejects a group from a sibling page", async () => { + await withTestTransaction(async (tx) => { + // Workspace scope alone isn't enough — a sibling page's group would + // render the component under a group it isn't on. + const otherPage = await makeSecondPage(tx); + const foreignGroup = await makeGroup( + tx, + otherPage.id, + `${TEST_PREFIX}-grp-foreign`, + ); + + await expect( + createPageComponent({ + ctx: { ...teamCtx, db: tx }, + input: { + pageId: testPageId, + type: "static", + name: `${TEST_PREFIX}-bad-group`, + order: 0, + groupId: foreignGroup.id, + }, + }), + ).rejects.toBeInstanceOf(NotFoundError); + }); + }); + + test("updatePageComponent rejects a group from a sibling page", async () => { + await withTestTransaction(async (tx) => { + const teamCtxTx = { ...teamCtx, db: tx }; + const otherPage = await makeSecondPage(tx); + const foreignGroup = await makeGroup( + tx, + otherPage.id, + `${TEST_PREFIX}-grp-foreign-2`, + ); + const created = await createPageComponent({ + ctx: teamCtxTx, + input: { + pageId: testPageId, + type: "static", + name: `${TEST_PREFIX}-regroup`, + order: 0, + }, + }); + + await expect( + updatePageComponent({ + ctx: teamCtxTx, + input: { id: created.id, groupId: foreignGroup.id }, + }), + ).rejects.toBeInstanceOf(NotFoundError); + }); + }); + + test("updatePageComponent clears the group with null", async () => { + await withTestTransaction(async (tx) => { + const teamCtxTx = { ...teamCtx, db: tx }; + const group = await makeGroup(tx, testPageId, `${TEST_PREFIX}-grp-clear`); + const created = await createPageComponent({ + ctx: teamCtxTx, + input: { + pageId: testPageId, + type: "static", + name: `${TEST_PREFIX}-ungroup`, + order: 0, + groupId: group.id, + }, + }); + expect(created.groupId).toBe(group.id); + + const updated = await updatePageComponent({ + ctx: teamCtxTx, + input: { id: created.id, groupId: null }, + }); + expect(updated.groupId).toBe(null); + }); + }); +}); diff --git a/packages/services/src/page-component/create.ts b/packages/services/src/page-component/create.ts new file mode 100644 index 00000000..ef7542d3 --- /dev/null +++ b/packages/services/src/page-component/create.ts @@ -0,0 +1,111 @@ +import { and, eq, isNull } from "@openstatus/db"; +import { monitor, pageComponent } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { requireScope } from "../auth"; +import { type ServiceContext, withTransaction } from "../context"; +import { ConflictError, ForbiddenError } from "../errors"; +import { assertWithinLimit } from "../limits"; +import { assertGroupOnPage, assertPageInWorkspace } from "./internal"; +import { CreatePageComponentInput } from "./schemas"; + +/** + * Add a single component to a status page. Monitor components inherit the + * monitor's name when the caller doesn't supply one. + */ +export async function createPageComponent(args: { + ctx: ServiceContext; + input: CreatePageComponentInput; +}) { + const { ctx } = args; + requireScope(ctx, "write"); + const input = CreatePageComponentInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + await assertPageInWorkspace({ + tx, + pageId: input.pageId, + workspaceId: ctx.workspace.id, + }); + + let name = input.name; + if (input.type === "monitor") { + // Soft-deleted monitors are excluded — a tombstoned monitor's id + // shouldn't be attachable to a fresh component. + const row = await tx + .select({ id: monitor.id, name: monitor.name }) + .from(monitor) + .where( + and( + // safe: the schema's refine guarantees monitorId on this branch + eq(monitor.id, input.monitorId as number), + eq(monitor.workspaceId, ctx.workspace.id), + isNull(monitor.deletedAt), + ), + ) + .get(); + if (!row) throw new ForbiddenError("Invalid monitor IDs."); + name = name ?? row.name; + + // `(pageId, monitorId)` is UNIQUE — pre-check so a duplicate reads as a + // conflict rather than a raw driver constraint failure. + const duplicate = await tx + .select({ id: pageComponent.id }) + .from(pageComponent) + .where( + and( + eq(pageComponent.pageId, input.pageId), + eq(pageComponent.monitorId, row.id), + ), + ) + .get(); + if (duplicate) { + throw new ConflictError( + "This monitor is already a component on this page.", + ); + } + } + + if (input.groupId != null) { + await assertGroupOnPage({ + tx, + groupId: input.groupId, + pageId: input.pageId, + workspaceId: ctx.workspace.id, + }); + } + + // Validation before quota, as in `notification/create`: a bad monitor id + // or a re-added component must report what's actually wrong rather than + // "limit reached", and neither one would consume a slot anyway. + await assertWithinLimit({ + tx, + workspaceId: ctx.workspace.id, + limit: "page-components", + }); + + const created = await tx + .insert(pageComponent) + .values({ + workspaceId: ctx.workspace.id, + pageId: input.pageId, + type: input.type, + monitorId: input.type === "monitor" ? input.monitorId : null, + name: name ?? "", + description: input.description ?? null, + order: input.order, + groupId: input.groupId ?? null, + }) + .returning() + .get(); + + await emitAudit(tx, ctx, { + action: "page_component.create", + entityType: "page_component", + entityId: created.id, + after: created, + }); + + return created; + }); +} diff --git a/packages/services/src/page-component/index.ts b/packages/services/src/page-component/index.ts index e22ccfd9..d3c256bd 100644 --- a/packages/services/src/page-component/index.ts +++ b/packages/services/src/page-component/index.ts @@ -1,4 +1,6 @@ +export { createPageComponent } from "./create"; export { deletePageComponent } from "./delete"; +export { updatePageComponent } from "./update"; export { type ComponentDayBucket, type ComponentEventSummary, @@ -10,8 +12,10 @@ export { listPageComponents, type PageComponentWithRelations } from "./list"; export { updatePageComponentOrder } from "./update-order"; export { + CreatePageComponentInput, DeletePageComponentInput, GetPageComponentDailySummaryInput, ListPageComponentsInput, + UpdatePageComponentInput, UpdatePageComponentOrderInput, } from "./schemas"; diff --git a/packages/services/src/page-component/internal.ts b/packages/services/src/page-component/internal.ts index fa05e798..c2a8c44d 100644 --- a/packages/services/src/page-component/internal.ts +++ b/packages/services/src/page-component/internal.ts @@ -1,8 +1,13 @@ import { and, eq, inArray, isNull } from "@openstatus/db"; -import { monitor, page } from "@openstatus/db/src/schema"; +import { + monitor, + page, + pageComponent, + pageComponentGroup, +} from "@openstatus/db/src/schema"; import type { DB } from "../context"; -import { ForbiddenError } from "../errors"; +import { ForbiddenError, NotFoundError } from "../errors"; /** Assert a page is in the workspace. Throws `ForbiddenError` otherwise. */ export async function assertPageInWorkspace(args: { @@ -19,6 +24,52 @@ export async function assertPageInWorkspace(args: { if (!row) throw new ForbiddenError("You don't have access to this page."); } +/** Load a component by id, scoped to the workspace. */ +export async function getPageComponentInWorkspace(args: { + tx: DB; + id: number; + workspaceId: number; +}) { + const { tx, id, workspaceId } = args; + const row = await tx + .select() + .from(pageComponent) + .where( + and(eq(pageComponent.id, id), eq(pageComponent.workspaceId, workspaceId)), + ) + .get(); + if (!row) throw new NotFoundError("page_component", id); + return row; +} + +/** + * Assert a group exists and sits on `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. + */ +export async function assertGroupOnPage(args: { + tx: DB; + groupId: number; + pageId: number; + workspaceId: number; +}): Promise { + const { tx, groupId, pageId, workspaceId } = args; + const row = await tx + .select({ pageId: pageComponentGroup.pageId }) + .from(pageComponentGroup) + .where( + and( + eq(pageComponentGroup.id, groupId), + eq(pageComponentGroup.workspaceId, workspaceId), + ), + ) + .get(); + if (!row || row.pageId !== pageId) { + throw new NotFoundError("page_component_group", groupId); + } +} + /** * Verify the supplied monitor ids all belong to the workspace. Duplicated * from `monitor/internal.ts` deliberately; a shared `packages/services/src/ diff --git a/packages/services/src/page-component/schemas.ts b/packages/services/src/page-component/schemas.ts index 3ec13e47..8b6546d1 100644 --- a/packages/services/src/page-component/schemas.ts +++ b/packages/services/src/page-component/schemas.ts @@ -53,6 +53,45 @@ export const DeletePageComponentInput = z.object({ }); export type DeletePageComponentInput = z.infer; +// Same monitor/static invariant as `componentInput` above, expressed for +// the single-component create path. +export const CreatePageComponentInput = z + .object({ + pageId: z.number().int(), + type: z.enum(["monitor", "static"]), + monitorId: z.number().int().nullish(), + name: z.string().min(1).optional(), + description: z.string().nullish(), + order: z.number().int().default(0), + groupId: z.number().int().nullish(), + }) + .refine( + (c) => (c.type === "monitor" ? c.monitorId != null : c.monitorId == null), + { + path: ["monitorId"], + message: + "Monitor components require a monitorId; static components must not set one.", + }, + ) + .refine((c) => c.type === "monitor" || (c.name?.length ?? 0) > 0, { + path: ["name"], + message: "Static components require a name.", + }); +// `z.input`, not `z.infer` — the output type marks defaulted fields required, +// which would force callers to pass what the schema already defaults. +export type CreatePageComponentInput = z.input; + +/** Partial patch — `undefined` leaves a field as-is, `null` clears it. */ +export const UpdatePageComponentInput = z.object({ + id: z.number().int(), + name: z.string().min(1).optional(), + description: z.string().nullish(), + order: z.number().int().optional(), + groupId: z.number().int().nullish(), + groupOrder: z.number().int().optional(), +}); +export type UpdatePageComponentInput = z.infer; + export const UpdatePageComponentOrderInput = z.object({ pageId: z.number().int(), components: z.array(componentInput), diff --git a/packages/services/src/page-component/update.ts b/packages/services/src/page-component/update.ts new file mode 100644 index 00000000..88785d87 --- /dev/null +++ b/packages/services/src/page-component/update.ts @@ -0,0 +1,60 @@ +import { eq } from "@openstatus/db"; +import { pageComponent } from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { requireScope } from "../auth"; +import { type ServiceContext, withTransaction } from "../context"; +import { assertGroupOnPage, getPageComponentInWorkspace } from "./internal"; +import { UpdatePageComponentInput } from "./schemas"; + +export async function updatePageComponent(args: { + ctx: ServiceContext; + input: UpdatePageComponentInput; +}) { + const { ctx } = args; + requireScope(ctx, "write"); + const input = UpdatePageComponentInput.parse(args.input); + + return withTransaction(ctx, async (tx) => { + const existing = await getPageComponentInWorkspace({ + tx, + id: input.id, + workspaceId: ctx.workspace.id, + }); + + if (input.groupId != null) { + await assertGroupOnPage({ + tx, + groupId: input.groupId, + pageId: existing.pageId, + workspaceId: ctx.workspace.id, + }); + } + + const values: Record = { updatedAt: new Date() }; + if (input.name !== undefined) values.name = input.name; + if (input.description !== undefined) { + values.description = input.description; + } + if (input.order !== undefined) values.order = input.order; + if (input.groupId !== undefined) values.groupId = input.groupId; + if (input.groupOrder !== undefined) values.groupOrder = input.groupOrder; + + const updated = await tx + .update(pageComponent) + .set(values) + .where(eq(pageComponent.id, existing.id)) + .returning() + .get(); + + await emitAudit(tx, ctx, { + action: "page_component.update", + entityType: "page_component", + entityId: existing.id, + before: existing, + after: updated, + }); + + return updated; + }); +} diff --git a/packages/services/src/page-subscriber/__tests__/page-subscriber.test.ts b/packages/services/src/page-subscriber/__tests__/page-subscriber.test.ts index a00a5bca..7fce9a0d 100644 --- a/packages/services/src/page-subscriber/__tests__/page-subscriber.test.ts +++ b/packages/services/src/page-subscriber/__tests__/page-subscriber.test.ts @@ -32,6 +32,7 @@ import { createPageSubscriber, getSubscriberByToken, hasPendingSubscriber, + unsubscribePageSubscriber, unsubscribeSubscriber, updateSubscriberScope, upsertSelfSignupSubscriber, @@ -41,6 +42,7 @@ import { // Built in `beforeAll` — this suite owns its workspace, page and components so // its committed rows and audit trail can't be observed or wiped by siblings. let WORKSPACE_ID: number; +let WORKSPACE: Awaited>["workspace"]; let FREE_WORKSPACE_ID: number; let PAGE_ID: number; let PAGE_SLUG: string; @@ -61,6 +63,9 @@ const EMAILS = { scopeUnverified: "svc-scope-unverified@example.com", scopeUnsubbed: "svc-scope-unsubbed@example.com", unsub: "svc-unsub-test@example.com", + unsubWorkspaceEmail: "svc-unsub-ws-email-test@example.com", + unsubWorkspaceId: "svc-unsub-ws-id-test@example.com", + unsubWorkspaceDenied: "svc-unsub-ws-denied-test@example.com", hasPending: "svc-has-pending-test@example.com", }; @@ -73,6 +78,7 @@ async function cleanAll() { beforeAll(async () => { const team = await createWorkspaceFixture("team"); + WORKSPACE = team.workspace; WORKSPACE_ID = team.workspace.id; FREE_WORKSPACE_ID = (await createWorkspaceFixture("free")).workspace.id; @@ -790,3 +796,188 @@ describe("createPageSubscriber", () => { }); }); }); + +// ─── unsubscribePageSubscriber ─────────────────────────────────────────────── + +describe("unsubscribePageSubscriber", () => { + function writeCtx() { + return makeApiKeyCtx(WORKSPACE, { keyId: "k-write", userId: 1 }); + } + + function readCtx() { + return makeApiKeyCtx(WORKSPACE, { + keyId: "k-read", + userId: 1, + scopes: ["read"], + }); + } + + async function seed(email: string) { + await db.delete(pageSubscriber).where(eq(pageSubscriber.email, email)); + return upsertSelfSignupSubscriber({ input: { email, pageId: PAGE_ID } }); + } + + test("rejects read-only actor", async () => { + await expect( + unsubscribePageSubscriber({ + ctx: readCtx(), + input: { + pageId: PAGE_ID, + identifier: { type: "email", value: EMAILS.unsubWorkspaceDenied }, + }, + }), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + + test("unsubscribes by email and emits an apiKey-actor audit row", async () => { + const email = EMAILS.unsubWorkspaceEmail; + const sub = await seed(email); + await clearAuditLog(WORKSPACE_ID); + + await expect( + unsubscribePageSubscriber({ + ctx: writeCtx(), + input: { pageId: PAGE_ID, identifier: { type: "email", value: email } }, + }), + ).resolves.toBeUndefined(); + + const row = await db.query.pageSubscriber.findFirst({ + where: eq(pageSubscriber.id, sub.id), + }); + expect(row?.unsubscribedAt).toBeDefined(); + + await expectAuditRow({ + workspaceId: WORKSPACE_ID, + action: "page_subscriber.update", + entityType: "page_subscriber", + entityId: sub.id, + actorType: "apiKey", + }); + }); + + test("matches email case-insensitively", async () => { + const email = EMAILS.unsubWorkspaceId; + const sub = await seed(email); + + await expect( + unsubscribePageSubscriber({ + ctx: writeCtx(), + input: { + pageId: PAGE_ID, + identifier: { type: "email", value: email.toUpperCase() }, + }, + }), + ).resolves.toBeUndefined(); + + const row = await db.query.pageSubscriber.findFirst({ + where: eq(pageSubscriber.id, sub.id), + }); + expect(row?.unsubscribedAt).toBeDefined(); + }); + + test("throws when the page belongs to another workspace", async () => { + const freeWorkspace = { ...WORKSPACE, id: FREE_WORKSPACE_ID }; + await expect( + unsubscribePageSubscriber({ + ctx: makeApiKeyCtx(freeWorkspace, { keyId: "k-other", userId: 2 }), + input: { + pageId: PAGE_ID, + identifier: { type: "email", value: EMAILS.unsubWorkspaceEmail }, + }, + }), + ).rejects.toThrow(); + }); + + test("throws for an unknown subscriber", async () => { + await expect( + unsubscribePageSubscriber({ + ctx: writeCtx(), + input: { pageId: PAGE_ID, identifier: { type: "id", value: 999_999 } }, + }), + ).rejects.toThrow(); + }); +}); + +describe("unsubscribePageSubscriber by id", () => { + test("unsubscribes an existing row addressed by id", async () => { + const email = "svc-unsub-ws-byid-test@example.com"; + await db.delete(pageSubscriber).where(eq(pageSubscriber.email, email)); + const sub = await upsertSelfSignupSubscriber({ + input: { email, pageId: PAGE_ID }, + }); + + await expect( + unsubscribePageSubscriber({ + ctx: makeApiKeyCtx(WORKSPACE, { keyId: "k-write", userId: 1 }), + input: { + pageId: PAGE_ID, + identifier: { type: "id", value: sub.id }, + }, + }), + ).resolves.toBeUndefined(); + + const row = await db.query.pageSubscriber.findFirst({ + where: eq(pageSubscriber.id, sub.id), + }); + expect(row?.unsubscribedAt).toBeDefined(); + + await db.delete(pageSubscriber).where(eq(pageSubscriber.email, email)); + }); + + test("an already-unsubscribed email is not found again", async () => { + // The email lookup filters on `unsubscribedAt IS NULL`, so a repeat + // call must not silently succeed against a stale row. + const email = "svc-unsub-ws-stale-test@example.com"; + await db.delete(pageSubscriber).where(eq(pageSubscriber.email, email)); + await upsertSelfSignupSubscriber({ input: { email, pageId: PAGE_ID } }); + const ctx = makeApiKeyCtx(WORKSPACE, { keyId: "k-write", userId: 1 }); + const input = { + pageId: PAGE_ID, + identifier: { type: "email" as const, value: email }, + }; + + await expect( + unsubscribePageSubscriber({ ctx, input }), + ).resolves.toBeUndefined(); + await expect(unsubscribePageSubscriber({ ctx, input })).rejects.toThrow(); + + await db.delete(pageSubscriber).where(eq(pageSubscriber.email, email)); + }); + + test("a repeat unsubscribe by id is a no-op", async () => { + const email = "svc-unsub-ws-byid-repeat-test@example.com"; + await db.delete(pageSubscriber).where(eq(pageSubscriber.email, email)); + const sub = await upsertSelfSignupSubscriber({ + input: { email, pageId: PAGE_ID }, + }); + const ctx = makeApiKeyCtx(WORKSPACE, { keyId: "k-write", userId: 1 }); + const input = { + pageId: PAGE_ID, + identifier: { type: "id" as const, value: sub.id }, + }; + + await unsubscribePageSubscriber({ ctx, input }); + const first = await db.query.pageSubscriber.findFirst({ + where: eq(pageSubscriber.id, sub.id), + }); + await clearAuditLog(WORKSPACE_ID); + + await expect( + unsubscribePageSubscriber({ ctx, input }), + ).resolves.toBeUndefined(); + + const second = await db.query.pageSubscriber.findFirst({ + where: eq(pageSubscriber.id, sub.id), + }); + expect(second?.unsubscribedAt).toEqual(first?.unsubscribedAt); + + const rows = await readAuditLog({ + workspaceId: WORKSPACE_ID, + entityType: "page_subscriber", + entityId: String(sub.id), + }); + expect(rows).toHaveLength(0); + + await db.delete(pageSubscriber).where(eq(pageSubscriber.email, email)); + }); +}); diff --git a/packages/services/src/page-subscriber/index.ts b/packages/services/src/page-subscriber/index.ts index 793b3503..98da623d 100644 --- a/packages/services/src/page-subscriber/index.ts +++ b/packages/services/src/page-subscriber/index.ts @@ -18,6 +18,7 @@ export { type SlackSubscriptionSummary, } from "./slack"; export { unsubscribeSubscriber } from "./unsubscribe"; +export { unsubscribePageSubscriber } from "./unsubscribe-in-workspace"; export { updatePageSubscriberChannel } from "./update"; export { updateSubscriberScope } from "./update-scope"; export { @@ -35,6 +36,7 @@ export { ListSlackSubscribersInput, RemoveSlackSubscriberInput, SendPageSubscriberTestWebhookInput, + UnsubscribePageSubscriberInput, UnsubscribeSubscriberInput, UpdatePageSubscriberChannelInput, UpdateSubscriberScopeInput, diff --git a/packages/services/src/page-subscriber/schemas.ts b/packages/services/src/page-subscriber/schemas.ts index 5db26ade..b77d4185 100644 --- a/packages/services/src/page-subscriber/schemas.ts +++ b/packages/services/src/page-subscriber/schemas.ts @@ -144,3 +144,22 @@ export const UnsubscribeSubscriberInput = z.object({ export type UnsubscribeSubscriberInput = z.infer< typeof UnsubscribeSubscriberInput >; + +/** + * Workspace-scoped unsubscribe for the management API, where the caller + * holds a key rather than a subscriber's token. Addressed by row id or by + * email within one page. + */ +export const UnsubscribePageSubscriberInput = z.object({ + pageId: z.number().int().positive(), + identifier: z.discriminatedUnion("type", [ + z.object({ type: z.literal("id"), value: z.number().int().positive() }), + z.object({ + type: z.literal("email"), + value: z.email().toLowerCase(), + }), + ]), +}); +export type UnsubscribePageSubscriberInput = z.infer< + typeof UnsubscribePageSubscriberInput +>; diff --git a/packages/services/src/page-subscriber/unsubscribe-in-workspace.ts b/packages/services/src/page-subscriber/unsubscribe-in-workspace.ts new file mode 100644 index 00000000..d99572ab --- /dev/null +++ b/packages/services/src/page-subscriber/unsubscribe-in-workspace.ts @@ -0,0 +1,79 @@ +import { and, eq, isNull, sql } from "@openstatus/db"; +import { + pageSubscriber, + selectPageSubscriberSchema, +} from "@openstatus/db/src/schema"; + +import { emitAudit } from "../audit"; +import { requireScope } from "../auth"; +import { type ServiceContext, withTransaction } from "../context"; +import { NotFoundError } from "../errors"; +import { loadPageForWorkspace } from "./internal"; +import { UnsubscribePageSubscriberInput } from "./schemas"; + +/** + * Unsubscribe on behalf of a workspace operator. Distinct from + * `unsubscribeSubscriber`, which is addressed by the subscriber's own + * token and carries a `subscriber` actor. + * + * Email lookup matches only still-subscribed email rows (an address can + * recur once unsubscribed); id lookup takes the row as-is and is + * idempotent on an already-unsubscribed row. + */ +export async function unsubscribePageSubscriber(args: { + ctx: ServiceContext; + input: UnsubscribePageSubscriberInput; +}): Promise { + const { ctx } = args; + requireScope(ctx, "write"); + const input = UnsubscribePageSubscriberInput.parse(args.input); + + await withTransaction(ctx, async (tx) => { + await loadPageForWorkspace({ + tx, + pageId: input.pageId, + workspaceId: ctx.workspace.id, + }); + + const where = + input.identifier.type === "email" + ? and( + eq(pageSubscriber.pageId, input.pageId), + sql`LOWER(${pageSubscriber.email}) = ${input.identifier.value}`, + eq(pageSubscriber.channelType, "email"), + isNull(pageSubscriber.unsubscribedAt), + ) + : and( + eq(pageSubscriber.pageId, input.pageId), + eq(pageSubscriber.id, input.identifier.value), + ); + + const existing = await tx.select().from(pageSubscriber).where(where).get(); + if (!existing) { + throw new NotFoundError("page_subscriber", input.identifier.value); + } + + // Already unsubscribed (only reachable on the id path — the email filter + // excludes these). Returning here keeps the repeat call a true no-op + // instead of moving `unsubscribedAt` and emitting a second audit row. + if (existing.unsubscribedAt) return; + + const updated = await tx + .update(pageSubscriber) + .set({ unsubscribedAt: new Date(), updatedAt: new Date() }) + .where(eq(pageSubscriber.id, existing.id)) + .returning() + .get(); + + const { token: _bt, ...before } = + selectPageSubscriberSchema.parse(existing); + const { token: _at, ...after } = selectPageSubscriberSchema.parse(updated); + await emitAudit(tx, ctx, { + action: "page_subscriber.update", + entityType: "page_subscriber", + entityId: existing.id, + before, + after, + }); + }); +} diff --git a/packages/services/src/page/__tests__/page.test.ts b/packages/services/src/page/__tests__/page.test.ts index 84e6ed4e..bfedf37f 100644 --- a/packages/services/src/page/__tests__/page.test.ts +++ b/packages/services/src/page/__tests__/page.test.ts @@ -633,3 +633,47 @@ describe("deletePage", () => { }); }); }); + +describe("status-page count quota", () => { + test("createPage rejects once the plan's page cap is spent", async () => { + await withTestTransaction(async (tx) => { + // Free plan caps status pages at 1. + const ctx = { ...freeCtx, db: tx }; + await newPage({ + ctx, + input: { title: "Quota 1", slug: uniqueSlug("quota-1") }, + }); + + await expect( + createPage({ + ctx, + input: { + title: "Quota 2", + description: "", + slug: uniqueSlug("quota-2"), + customDomain: "", + workspaceId: ctx.workspace.id, + monitors: [], + }, + }), + ).rejects.toBeInstanceOf(LimitExceededError); + }); + }); + + test("newPage rejects once the cap is spent", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...freeCtx, db: tx }; + await newPage({ + ctx, + input: { title: "Quota 3", slug: uniqueSlug("quota-3") }, + }); + + await expect( + newPage({ + ctx, + input: { title: "Quota 4", slug: uniqueSlug("quota-4") }, + }), + ).rejects.toBeInstanceOf(LimitExceededError); + }); + }); +}); diff --git a/packages/services/src/page/create.ts b/packages/services/src/page/create.ts index 028029f1..7e4fa9ab 100644 --- a/packages/services/src/page/create.ts +++ b/packages/services/src/page/create.ts @@ -7,11 +7,11 @@ import { import { emitAudit } from "../audit"; import { requireScope } from "../auth"; import { type ServiceContext, withTransaction } from "../context"; +import { assertWithinLimit } from "../limits"; import type { Page } from "../types"; import { assertAccessTypeAllowed, assertSlugAvailable, - assertStatusPageQuota, validateMonitorIdsActive, } from "./internal"; import { CreatePageInput, NewPageInput } from "./schemas"; @@ -26,7 +26,11 @@ export async function createPage(args: { const input = CreatePageInput.parse(args.input); return withTransaction(ctx, async (tx) => { - await assertStatusPageQuota(tx, ctx.workspace); + await assertWithinLimit({ + tx, + workspaceId: ctx.workspace.id, + limit: "status-pages", + }); await assertSlugAvailable({ tx, slug: input.slug }); assertAccessTypeAllowed(ctx.workspace, { accessType: input.accessType ?? "public", @@ -115,7 +119,11 @@ export async function newPage(args: { const input = NewPageInput.parse(args.input); return withTransaction(ctx, async (tx) => { - await assertStatusPageQuota(tx, ctx.workspace); + await assertWithinLimit({ + tx, + workspaceId: ctx.workspace.id, + limit: "status-pages", + }); await assertSlugAvailable({ tx, slug: input.slug }); const defaultConfiguration = { diff --git a/packages/services/src/page/internal.ts b/packages/services/src/page/internal.ts index d3aba7bc..ae8afa79 100644 --- a/packages/services/src/page/internal.ts +++ b/packages/services/src/page/internal.ts @@ -1,4 +1,4 @@ -import { and, count, eq, inArray, isNull, sql } from "@openstatus/db"; +import { and, eq, inArray, isNull, sql } from "@openstatus/db"; import { monitor, page } from "@openstatus/db/src/schema"; import { type pageAccessTypes, @@ -31,19 +31,6 @@ export async function getPageInWorkspace(args: { return row; } -/** Count the workspace's pages. */ -export async function countPagesInWorkspace( - tx: DB, - workspaceId: number, -): Promise { - const res = await tx - .select({ count: count() }) - .from(page) - .where(eq(page.workspaceId, workspaceId)) - .get(); - return res?.count ?? 0; -} - /** * Assert a slug is free — rejects reserved subdomains and already-taken * slugs. Optionally exempts a page id (for updates where the current slug @@ -161,17 +148,3 @@ export function assertAccessTypeAllowed( throw new LimitExceededError("no-index", 0); } } - -/** Plan gate on the workspace's `status-pages` cap. */ -export async function assertStatusPageQuota( - tx: DB, - workspace: Workspace, -): Promise { - const current = await countPagesInWorkspace(tx, workspace.id); - if (current >= workspace.limits["status-pages"]) { - throw new LimitExceededError( - "status-pages", - workspace.limits["status-pages"], - ); - } -} -- 2.51.2