From eae39f647a925681e7b8dcb7a15cb7683317dc76 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 28 Oct 2026 16:13:30 +0100 Subject: [PATCH 01/10] feat(status-pages): public v2 API for pages, components and incidents --- .../src/routes/v2/status-pages.http.test.ts | 444 ++++++++++ apps/api/src/routes/v2/status-pages.http.ts | 585 +++++++++++++ apps/api/src/routes/v2/v2-test-support.ts | 6 + apps/api/src/runtime/http-graph.ts | 3 + docs/api-v2.md | 4 +- packages/domain/src/audit-actions.ts | 39 + packages/domain/src/http/v2/api.ts | 3 + packages/domain/src/http/v2/index.ts | 1 + packages/domain/src/http/v2/openapi.test.ts | 19 + packages/domain/src/http/v2/public-id.ts | 4 + packages/domain/src/http/v2/status-pages.ts | 816 ++++++++++++++++++ 11 files changed, 1923 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/routes/v2/status-pages.http.test.ts create mode 100644 apps/api/src/routes/v2/status-pages.http.ts create mode 100644 packages/domain/src/http/v2/status-pages.ts diff --git a/apps/api/src/routes/v2/status-pages.http.test.ts b/apps/api/src/routes/v2/status-pages.http.test.ts new file mode 100644 index 0000000000..d805cf250b --- /dev/null +++ b/apps/api/src/routes/v2/status-pages.http.test.ts @@ -0,0 +1,444 @@ +import { afterEach, describe, expect, it } from "@effect/vitest" +import { ConfigProvider, Context, Effect, Layer, ManagedRuntime, Schema } from "effect" +import { HttpRouter } from "effect/http" +import { HttpApiBuilder } from "effect/http-api" +import { OrgId, UserId } from "@maple/domain/http" +import { MapleApiV2 } from "@maple/domain/http/v2" +import { cleanupTestDbs, createTestDb, type TestDb } from "@maple/backend/platform/test-pglite" +import { Env } from "@maple/backend/platform/Env" +import { ApiAuthorizationV2Live } from "@maple/backend/services/auth/ApiAuthorizationV2Live" +import { AuditLogService } from "@maple/backend/services/audit/AuditLogService" +import { ApiKeysService } from "@maple/backend/services/org/ApiKeysService" +import { AuthService } from "@maple/backend/services/auth/AuthService" +import { DashboardPersistenceService } from "@maple/backend/services/dashboards/DashboardPersistenceService" +import { SharedDashboardService } from "@maple/backend/services/dashboards/SharedDashboardService" +import { LiveActivitiesService } from "@maple/backend/services/push/LiveActivitiesService" +import { MobileDevicesService } from "@maple/backend/services/push/MobileDevicesService" +import { V2TransportErrorBoundaryLive } from "./error-envelope" +import { + AlertsServiceStubLayer, + AllV2GroupLayersLive, + ApiV2RateLimiterAllowAllLayer, + ConfigResourceServiceStubsLayer, + Phase1ResourceStubsLayer, + PlanetScaleServiceStubsLayer, + TelemetryServiceStubsLayer, +} from "./v2-test-support" + +/** `/v2/status_pages` and `/v2/status_page_incidents` over an embedded PGlite with the real service. */ + +const createdDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(createdDbs)) + +const ORG = Schema.decodeUnknownSync(OrgId)("org_status_pages_e2e") +const OTHER_ORG = Schema.decodeUnknownSync(OrgId)("org_status_pages_other") +const USER = Schema.decodeUnknownSync(UserId)("user_status_pages_e2e") +const MEMBER = { source: "maple_cli", roles: ["org:member"], deviceName: "laptop" } + +const testConfig = () => + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + PORT: "3488", + MCP_PORT: "3489", + TINYBIRD_HOST: "https://api.tinybird.co", + TINYBIRD_TOKEN: "test-token", + MAPLE_AUTH_MODE: "self_hosted", + MAPLE_ROOT_PASSWORD: "test-root-password", + MAPLE_DEFAULT_ORG_ID: "default", + MAPLE_API_BASE_URL: "https://api.maple.test", + MAPLE_INGEST_KEY_ENCRYPTION_KEY: Buffer.alloc(32, 1).toString("base64"), + MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: "maple-test-lookup-secret", + INTERNAL_SERVICE_TOKEN: "test-internal-token", + }), + ) + +const makeHarness = () => { + const testDb = createTestDb(createdDbs) + const envLive = Env.layer.pipe(Layer.provide(testConfig())) + const servicesLive = Layer.mergeAll( + ApiKeysService.layer, + AuthService.layer, + DashboardPersistenceService.layer, + SharedDashboardService.layer, + MobileDevicesService.layer, + LiveActivitiesService.layer, + ).pipe(Layer.provideMerge(Layer.mergeAll(envLive, testDb.layer))) + + const routes = HttpApiBuilder.layer(MapleApiV2).pipe( + Layer.provide(AllV2GroupLayersLive), + Layer.provide(V2TransportErrorBoundaryLive), + Layer.provide(AlertsServiceStubLayer), + Layer.provide(ConfigResourceServiceStubsLayer), + Layer.provide(Phase1ResourceStubsLayer), + Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(TelemetryServiceStubsLayer), + Layer.provideMerge(ApiAuthorizationV2Live), + Layer.provideMerge(AuditLogService.layerMemory), + Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), + Layer.provideMerge(servicesLive), + ) + const { handler, dispose: disposeHandler } = HttpRouter.toWebHandler(routes, { disableLogger: true }) + const runtime = ManagedRuntime.make(servicesLive) + + const request = async (method: string, path: string, token: string, body?: unknown) => { + const response = await handler( + new Request(`http://maple.test${path}`, { + method, + headers: { + authorization: `Bearer ${token}`, + ...(body !== undefined ? { "content-type": "application/json" } : undefined), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }), + Context.empty() as never, + ) + const text = await response.text() + return { status: response.status, body: text.length === 0 ? null : JSON.parse(text) } + } + + const bootstrapKey = (orgId: OrgId = ORG, metadataJson?: Record) => + runtime.runPromise( + Effect.gen(function* () { + const service = yield* ApiKeysService + return yield* service.create(orgId, USER, { name: "status-pages-test", metadataJson }) + }), + ) + + return { + request, + bootstrapKey, + dispose: async () => { + await disposeHandler() + await runtime.dispose() + }, + } +} + +const withHarness = (body: (harness: ReturnType) => Promise) => + Effect.runPromise( + Effect.acquireUseRelease( + Effect.sync(makeHarness), + (harness) => Effect.promise(() => body(harness)), + (harness) => Effect.promise(() => harness.dispose()), + ), + ) + +describe("v2 status pages", () => { + it("creates, retrieves, updates and deletes a page", () => + withHarness(async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const created = await harness.request("POST", "/v2/status_pages", token, { + name: "Acme Status", + slug: "acme-status", + brand_color: "#1A2B3C", + }) + expect(created.status).toBe(200) + expect(created.body).toMatchObject({ + object: "status_page", + name: "Acme Status", + slug: "acme-status", + brand_color: "#1a2b3c", + auto_incidents: "off", + revision: 1, + published_revision: 0, + published_at: null, + }) + const id: string = created.body.id + expect(id).toMatch(/^stp_/) + + const retrieved = await harness.request("GET", `/v2/status_pages/${id}`, token) + expect(retrieved.body).toEqual(created.body) + + const updated = await harness.request("PATCH", `/v2/status_pages/${id}`, token, { + description: "Live status of Acme.", + auto_incidents: "draft", + }) + expect(updated.status).toBe(200) + expect(updated.body).toMatchObject({ + name: "Acme Status", + description: "Live status of Acme.", + auto_incidents: "draft", + }) + expect(updated.body.revision).toBeGreaterThan(created.body.revision) + + const listed = await harness.request("GET", "/v2/status_pages", token) + expect(listed.body.data.map((page: { id: string }) => page.id)).toEqual([id]) + + // No edge storage is bound in the harness, so the minutely sweep owns publishing. + const published = await harness.request("POST", `/v2/status_pages/${id}/publish`, token) + expect(published.body).toEqual({ + id, + object: "status_page.publish_result", + outcome: "queued", + revision: updated.body.revision, + }) + + const removed = await harness.request("DELETE", `/v2/status_pages/${id}`, token) + expect(removed.body).toEqual({ id, object: "status_page", deleted: true }) + const missing = await harness.request("GET", `/v2/status_pages/${id}`, token) + expect(missing.status).toBe(404) + expect(missing.body.error.code).toBe("status_page_not_found") + })) + + it("refuses a taken slug and an invalid one", () => + withHarness(async (harness) => { + const { secret: token } = await harness.bootstrapKey() + await harness.request("POST", "/v2/status_pages", token, { name: "A", slug: "acme-status" }) + const taken = await harness.request("POST", "/v2/status_pages", token, { + name: "B", + slug: "acme-status", + }) + expect(taken.status).toBe(409) + expect(taken.body.error.code).toBe("status_page_slug_taken") + const invalid = await harness.request("POST", "/v2/status_pages", token, { + name: "C", + slug: "Not A Slug", + }) + expect(invalid.status).toBe(400) + expect(invalid.body.error.code).toBe("status_page_invalid") + })) + + it("issues a break-glass token once and revokes it", () => + withHarness(async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const page = await harness.request("POST", "/v2/status_pages", token, { + name: "Acme", + slug: "acme-glass", + }) + const issued = await harness.request( + "POST", + `/v2/status_pages/${page.body.id}/rotate_break_glass`, + token, + ) + expect(issued.body.token).toMatch(/^mstat_/) + expect(issued.body.token_suffix).toBe(issued.body.token.slice(-6)) + const retrieved = await harness.request("GET", `/v2/status_pages/${page.body.id}`, token) + expect(retrieved.body.break_glass_token_suffix).toBe(issued.body.token_suffix) + expect(JSON.stringify(retrieved.body)).not.toContain(issued.body.token) + + const revoked = await harness.request( + "DELETE", + `/v2/status_pages/${page.body.id}/break_glass`, + token, + ) + expect(revoked.body.break_glass_token_suffix).toBeNull() + })) + + it("adds, reorders, updates and removes components", () => + withHarness(async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const page = await harness.request("POST", "/v2/status_pages", token, { + name: "Acme", + slug: "acme-parts", + }) + const base = `/v2/status_pages/${page.body.id}/components` + const api = await harness.request("POST", base, token, { name: "API" }) + const web = await harness.request("POST", base, token, { name: "Dashboard", group: "Apps" }) + expect(api.body).toMatchObject({ object: "status_page_component", position: 0, group: null }) + expect(web.body).toMatchObject({ position: 1, group: "Apps" }) + + const reordered = await harness.request("POST", `${base}/reorder`, token, { + component_ids: [web.body.id, api.body.id], + }) + expect(reordered.body.data.map((c: { name: string }) => c.name)).toEqual(["Dashboard", "API"]) + const partial = await harness.request("POST", `${base}/reorder`, token, { + component_ids: [web.body.id], + }) + expect(partial.status).toBe(400) + + const updated = await harness.request("PATCH", `${base}/${api.body.id}`, token, { + manual_status: "degraded", + }) + expect(updated.body).toMatchObject({ name: "API", manual_status: "degraded" }) + + // A component addressed through another page is not found. + const other = await harness.request("POST", "/v2/status_pages", token, { + name: "Other", + slug: "acme-other", + }) + const crossPage = await harness.request( + "PATCH", + `/v2/status_pages/${other.body.id}/components/${api.body.id}`, + token, + { name: "Hijack" }, + ) + expect(crossPage.status).toBe(404) + expect(crossPage.body.error.code).toBe("status_page_component_not_found") + + const removed = await harness.request("DELETE", `${base}/${api.body.id}`, token) + expect(removed.body).toEqual({ id: api.body.id, object: "status_page_component", deleted: true }) + const listed = await harness.request("GET", base, token) + expect(listed.body.data.map((c: { id: string }) => c.id)).toEqual([web.body.id]) + })) + + it("runs an incident from draft to resolved", () => + withHarness(async (harness) => { + const { secret: admin } = await harness.bootstrapKey() + const { secret: member } = await harness.bootstrapKey(ORG, MEMBER) + const page = await harness.request("POST", "/v2/status_pages", admin, { + name: "Acme", + slug: "acme-incidents", + }) + const component = await harness.request( + "POST", + `/v2/status_pages/${page.body.id}/components`, + admin, + { + name: "API", + }, + ) + + // Responders post incidents without being org admins. + const opened = await harness.request("POST", "/v2/status_page_incidents", member, { + page_id: page.body.id, + title: "Elevated API errors", + body: "We are looking into elevated error rates.", + component_ids: [component.body.id], + published: false, + }) + expect(opened.status).toBe(200) + expect(opened.body).toMatchObject({ + object: "status_page_incident", + page_id: page.body.id, + kind: "incident", + impact: "minor", + status: "investigating", + published: false, + resolved_at: null, + component_ids: [component.body.id], + }) + expect(opened.body.updates).toHaveLength(1) + expect(opened.body.updates[0]).toMatchObject({ source: "api", status: "investigating" }) + const id: string = opened.body.id + + const published = await harness.request("PATCH", `/v2/status_page_incidents/${id}`, member, { + published: true, + impact: "major", + }) + expect(published.body).toMatchObject({ published: true, impact: "major" }) + + const resolved = await harness.request( + "POST", + `/v2/status_page_incidents/${id}/updates`, + member, + { + status: "resolved", + body: "Error rates are back to normal.", + }, + ) + expect(resolved.body.status).toBe("resolved") + expect(resolved.body.resolved_at).not.toBeNull() + expect(resolved.body.updates.map((u: { status: string }) => u.status)).toEqual([ + "resolved", + "investigating", + ]) + + const open = await harness.request( + "GET", + `/v2/status_page_incidents?page_id=${page.body.id}`, + member, + ) + expect(open.body.data).toEqual([]) + const closed = await harness.request("GET", "/v2/status_page_incidents?state=closed", member) + expect(closed.body.data.map((i: { id: string }) => i.id)).toEqual([id]) + + const wrongStatus = await harness.request( + "POST", + `/v2/status_page_incidents/${id}/updates`, + member, + { + status: "completed", + body: "Maintenance status on an incident.", + }, + ) + expect(wrongStatus.status).toBe(400) + + const removed = await harness.request("DELETE", `/v2/status_page_incidents/${id}`, member) + expect(removed.body).toEqual({ id, object: "status_page_incident", deleted: true }) + })) + + it("schedules maintenance with its window", () => + withHarness(async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const page = await harness.request("POST", "/v2/status_pages", token, { + name: "Acme", + slug: "acme-maint", + }) + const scheduled = await harness.request("POST", "/v2/status_page_incidents", token, { + page_id: page.body.id, + kind: "maintenance", + title: "Database upgrade", + body: "We are upgrading the primary database.", + scheduled_start: "2026-08-01T02:00:00.000Z", + scheduled_end: "2026-08-01T03:00:00.000Z", + }) + expect(scheduled.body).toMatchObject({ + kind: "maintenance", + status: "scheduled", + impact: "none", + scheduled_start: "2026-08-01T02:00:00.000Z", + scheduled_end: "2026-08-01T03:00:00.000Z", + started_at: "2026-08-01T02:00:00.000Z", + }) + const noWindow = await harness.request("POST", "/v2/status_page_incidents", token, { + page_id: page.body.id, + kind: "maintenance", + title: "Upgrade", + body: "Soon.", + }) + expect(noWindow.status).toBe(400) + })) + + it("refuses page and component writes from members", () => + withHarness(async (harness) => { + const { secret: member } = await harness.bootstrapKey(ORG, MEMBER) + const create = await harness.request("POST", "/v2/status_pages", member, { + name: "Acme", + slug: "acme-member", + }) + expect(create.status).toBe(403) + expect((await harness.request("GET", "/v2/status_pages", member)).status).toBe(200) + })) + + it("hides one org's pages and incidents from another", () => + withHarness(async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const { secret: outsider } = await harness.bootstrapKey(OTHER_ORG) + const page = await harness.request("POST", "/v2/status_pages", token, { + name: "Acme", + slug: "acme-private", + }) + const incident = await harness.request("POST", "/v2/status_page_incidents", token, { + page_id: page.body.id, + title: "Outage", + body: "Investigating.", + }) + + const pagePath = `/v2/status_pages/${page.body.id}` + const incidentPath = `/v2/status_page_incidents/${incident.body.id}` + const reads = await Promise.all([ + harness.request("GET", pagePath, outsider), + harness.request("PATCH", pagePath, outsider, { name: "Mine" }), + harness.request("DELETE", pagePath, outsider), + harness.request("GET", `${pagePath}/components`, outsider), + harness.request("GET", incidentPath, outsider), + harness.request("POST", `${incidentPath}/updates`, outsider, { + status: "resolved", + body: "Not yours.", + }), + harness.request("POST", "/v2/status_page_incidents", outsider, { + page_id: page.body.id, + title: "Fake", + body: "Fake.", + }), + ]) + expect(reads.map((r) => r.status)).toEqual([404, 404, 404, 404, 404, 404, 404]) + expect((await harness.request("GET", "/v2/status_pages", outsider)).body.data).toEqual([]) + expect((await harness.request("GET", "/v2/status_page_incidents", outsider)).body.data).toEqual( + [], + ) + + const intact = await harness.request("GET", incidentPath, token) + expect(intact.body.status).toBe("investigating") + })) +}) diff --git a/apps/api/src/routes/v2/status-pages.http.ts b/apps/api/src/routes/v2/status-pages.http.ts new file mode 100644 index 0000000000..b9bc2b172d --- /dev/null +++ b/apps/api/src/routes/v2/status-pages.http.ts @@ -0,0 +1,585 @@ +import { HttpApiBuilder } from "effect/http-api" +import { + CurrentTenant, + StatusPageComponentNotFoundError, + StatusPageNotFoundError, + StatusPagePersistenceError, + type StatusPageComponentRecord, + type StatusPageIncidentRecord, + type StatusPageRecord, +} from "@maple/domain/http" +import { + isoTimestamp, + isoTimestampOrNull, + MapleApiV2, + paginateArray, + V2InsufficientPermissions, + type V2StatusPage, + type V2StatusPageComponent, + type V2StatusPageIncident, +} from "@maple/domain/http/v2" +import type { + OrgId, + StatusPageComponentId, + StatusPageId, + StatusPageIncidentImpact, + StatusPageIncidentKind, + StatusPageIncidentStatus, +} from "@maple/domain/primitives" +import { isActiveIncident } from "@maple/domain/status-page" +import { Effect, Option } from "effect" +import { auditDiff } from "@/routes/v2/audit-changes" +import { recordHttpAudit } from "@maple/backend/services/audit/AuditLogService" +import { timestampMs } from "@maple/backend/platform/time" +import { requireAdmin } from "@maple/backend/services/auth/auth" +import { StatusPagePublisher } from "@maple/backend/services/status-pages/StatusPagePublisher" +import { StatusPagesService } from "@maple/backend/services/status-pages/StatusPagesService" +import { StatusPageStore } from "@maple/backend/services/status-pages/StatusPageStore" + +// Page settings, components and the break-glass token are admin-gated: they change what the +// public sees about the whole org. Incidents stay open to any writer, so responders can post. +const adminOnly = (action: string) => () => + V2InsufficientPermissions.make(`Only org admins can ${action} status pages`) + +export const toV2StatusPage = (page: StatusPageRecord): V2StatusPage => ({ + id: page.id, + object: "status_page", + name: page.name, + slug: page.slug, + description: page.description, + website_url: page.websiteUrl, + logo_url: page.logoUrl, + brand_color: page.brandColor, + show_powered_by: page.showPoweredBy, + auto_incidents: page.autoIncidents, + break_glass_token_suffix: page.breakGlassTokenSuffix, + revision: page.revision, + published_revision: page.publishedRevision, + published_at: isoTimestampOrNull(page.publishedAt), + last_publish_error: page.lastPublishError, + created_at: isoTimestamp(page.createdAt), + updated_at: isoTimestamp(page.updatedAt), +}) + +export const toV2StatusPageComponent = (component: StatusPageComponentRecord): V2StatusPageComponent => ({ + id: component.id, + object: "status_page_component", + page_id: component.pageId, + name: component.name, + description: component.description, + group: component.groupName, + position: component.position, + monitor_id: component.monitorId, + manual_status: component.manualStatus, + show_uptime: component.showUptime, + created_at: isoTimestamp(component.createdAt), + updated_at: isoTimestamp(component.updatedAt), +}) + +export const toV2StatusPageIncident = (incident: StatusPageIncidentRecord): V2StatusPageIncident => ({ + id: incident.id, + object: "status_page_incident", + page_id: incident.pageId, + kind: incident.kind, + title: incident.title, + impact: incident.impact, + status: incident.status, + published: incident.published, + scheduled_start: isoTimestampOrNull(incident.scheduledStart), + scheduled_end: isoTimestampOrNull(incident.scheduledEnd), + started_at: isoTimestamp(incident.startedAt), + resolved_at: isoTimestampOrNull(incident.resolvedAt), + component_ids: incident.componentIds, + updates: incident.updates.map((update) => ({ + id: update.id, + status: update.status, + body: update.body, + source: update.source, + created_at: isoTimestamp(update.createdAt), + })), + created_at: isoTimestamp(incident.createdAt), + updated_at: isoTimestamp(incident.updatedAt), +}) + +const pageAuditDiff = auditDiff({ + fields: [ + "name", + "slug", + "description", + "website_url", + "logo_url", + "brand_color", + "show_powered_by", + "auto_incidents", + ], +}) + +const componentAuditDiff = auditDiff({ + fields: ["name", "description", "group", "monitor_id", "manual_status", "show_uptime"], +}) + +const incidentAuditDiff = auditDiff({ + fields: ["title", "impact", "published", "scheduled_start", "scheduled_end", "component_ids"], +}) + +const DEFAULT_STATUS = { + incident: "investigating", + maintenance: "scheduled", +} as const satisfies Record + +const DEFAULT_IMPACT = { + incident: "minor", + maintenance: "none", +} as const satisfies Record + +const parseTimestamp = (value: string | null | undefined) => + value === undefined ? undefined : value === null ? null : timestampMs(value) + +const publishFailed = (message: string) => new StatusPagePersistenceError({ message }) + +export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPages", (handlers) => + Effect.gen(function* () { + const service = yield* StatusPagesService + + /** The component, only when it is on this page; anything else reads as not found. */ + const requireComponentOnPage = ( + orgId: OrgId, + pageId: StatusPageId, + componentId: StatusPageComponentId, + ) => + service.listComponents(orgId, pageId).pipe( + Effect.flatMap((components) => { + const component = components.find((candidate) => candidate.id === componentId) + return component === undefined + ? Effect.fail( + new StatusPageComponentNotFoundError({ + componentId, + message: "Status page component not found", + }), + ) + : Effect.succeed(component) + }), + ) + + return handlers + .handle("list", ({ query }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const pages = yield* service.list(tenant.orgId) + const page = yield* paginateArray(pages.map(toV2StatusPage), query) + return { object: "list" as const, ...page } + }), + ) + .handle("retrieve", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + return toV2StatusPage(yield* service.get(tenant.orgId, params.id)) + }), + ) + .handle("create", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly("create")) + const created = yield* service.create(tenant.orgId, { + name: payload.name, + slug: payload.slug, + ...(payload.description === undefined + ? undefined + : { description: payload.description }), + ...(payload.website_url === undefined + ? undefined + : { websiteUrl: payload.website_url }), + ...(payload.logo_url === undefined ? undefined : { logoUrl: payload.logo_url }), + ...(payload.brand_color === undefined + ? undefined + : { brandColor: payload.brand_color }), + ...(payload.show_powered_by === undefined + ? undefined + : { showPoweredBy: payload.show_powered_by }), + ...(payload.auto_incidents === undefined + ? undefined + : { autoIncidents: payload.auto_incidents }), + createdBy: tenant.userId, + }) + yield* recordHttpAudit("status_page.created", { + resourceId: created.id, + metadata: { name: created.name, slug: created.slug }, + }) + return toV2StatusPage(created) + }), + ) + .handle("update", ({ params, payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly("update")) + const current = yield* service.get(tenant.orgId, params.id) + const updated = yield* service.update(tenant.orgId, params.id, { + ...(payload.name === undefined ? undefined : { name: payload.name }), + ...(payload.slug === undefined ? undefined : { slug: payload.slug }), + ...(payload.description === undefined + ? undefined + : { description: payload.description }), + ...(payload.website_url === undefined + ? undefined + : { websiteUrl: payload.website_url }), + ...(payload.logo_url === undefined ? undefined : { logoUrl: payload.logo_url }), + ...(payload.brand_color === undefined + ? undefined + : { brandColor: payload.brand_color }), + ...(payload.show_powered_by === undefined + ? undefined + : { showPoweredBy: payload.show_powered_by }), + ...(payload.auto_incidents === undefined + ? undefined + : { autoIncidents: payload.auto_incidents }), + }) + yield* recordHttpAudit("status_page.updated", { + resourceId: updated.id, + changes: pageAuditDiff(payload, toV2StatusPage(current), toV2StatusPage(updated)), + metadata: { name: updated.name }, + }) + return toV2StatusPage(updated) + }), + ) + .handle("delete", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly("delete")) + const current = yield* service.get(tenant.orgId, params.id) + const deleted = yield* service.remove(tenant.orgId, params.id) + // Best effort: the row is gone, so the page must not stay up; a failure is logged. + const store = yield* Effect.serviceOption(StatusPageStore) + if (Option.isSome(store)) { + yield* store.value.deletePage(deleted).pipe( + Effect.andThen(store.value.deleteRoutes({ slug: current.slug, hostnames: [] })), + Effect.catch((error) => + Effect.logWarning("Status page edge data not deleted").pipe( + Effect.annotateLogs({ + "maple.status_page.id": deleted, + "error.type": error._tag, + "error.message": error.message, + }), + ), + ), + ) + } + yield* recordHttpAudit("status_page.deleted", { + resourceId: deleted, + metadata: { name: current.name, slug: current.slug }, + }) + return { id: deleted, object: "status_page" as const, deleted: true as const } + }), + ) + .handle("publish", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly("publish")) + const page = yield* service.get(tenant.orgId, params.id) + const publisher = yield* Effect.serviceOption(StatusPagePublisher) + // Without edge storage bound here the minutely sweep publishes it. + const outcome = Option.isNone(publisher) + ? { outcome: "queued" as const, revision: page.revision } + : yield* publisher.value.publish(page.id).pipe( + Effect.map((result) => ({ + outcome: result.written ? ("published" as const) : ("unchanged" as const), + revision: result.revision, + })), + Effect.catchTags({ + "@maple/backend/status-pages/StatusPageGoneError": () => + Effect.fail( + new StatusPageNotFoundError({ + pageId: page.id, + message: "Status page not found", + }), + ), + "@maple/backend/status-pages/StatusPagePublishDbError": (error) => + Effect.fail(publishFailed(error.message)), + "@maple/backend/status-pages/StatusPageStoreError": (error) => + Effect.fail(publishFailed(error.message)), + }), + ) + yield* recordHttpAudit("status_page.published", { + resourceId: page.id, + metadata: { outcome: outcome.outcome, revision: outcome.revision }, + }) + return { id: page.id, object: "status_page.publish_result" as const, ...outcome } + }), + ) + .handle("rotateBreakGlass", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly("issue break-glass tokens for")) + const token = yield* service.rotateBreakGlassToken(tenant.orgId, params.id) + yield* recordHttpAudit("status_page.break_glass_rotated", { resourceId: params.id }) + return { + id: params.id, + object: "status_page.break_glass_token" as const, + token, + token_suffix: token.slice(-6), + } + }), + ) + .handle("revokeBreakGlass", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly("revoke break-glass tokens for")) + yield* service.revokeBreakGlassToken(tenant.orgId, params.id) + yield* recordHttpAudit("status_page.break_glass_revoked", { resourceId: params.id }) + return toV2StatusPage(yield* service.get(tenant.orgId, params.id)) + }), + ) + .handle("listComponents", ({ params, query }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const components = yield* service.listComponents(tenant.orgId, params.id) + const page = yield* paginateArray(components.map(toV2StatusPageComponent), query) + return { object: "list" as const, ...page } + }), + ) + .handle("createComponent", ({ params, payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly("edit")) + const created = yield* service.createComponent(tenant.orgId, params.id, { + name: payload.name, + ...(payload.description === undefined + ? undefined + : { description: payload.description }), + ...(payload.group === undefined ? undefined : { groupName: payload.group }), + ...(payload.monitor_id === undefined ? undefined : { monitorId: payload.monitor_id }), + ...(payload.manual_status === undefined + ? undefined + : { manualStatus: payload.manual_status }), + ...(payload.show_uptime === undefined + ? undefined + : { showUptime: payload.show_uptime }), + }) + yield* recordHttpAudit("status_page_component.created", { + resourceId: created.id, + metadata: { name: created.name, page_id: params.id }, + }) + return toV2StatusPageComponent(created) + }), + ) + .handle("reorderComponents", ({ params, payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly("edit")) + const components = yield* service.reorderComponents( + tenant.orgId, + params.id, + payload.component_ids, + ) + yield* recordHttpAudit("status_page.components_reordered", { resourceId: params.id }) + return { + object: "list" as const, + data: components.map(toV2StatusPageComponent), + has_more: false, + next_cursor: null, + } + }), + ) + .handle("updateComponent", ({ params, payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly("edit")) + const current = yield* requireComponentOnPage( + tenant.orgId, + params.id, + params.component_id, + ) + const updated = yield* service.updateComponent(tenant.orgId, params.component_id, { + ...(payload.name === undefined ? undefined : { name: payload.name }), + ...(payload.description === undefined + ? undefined + : { description: payload.description }), + ...(payload.group === undefined ? undefined : { groupName: payload.group }), + ...(payload.monitor_id === undefined ? undefined : { monitorId: payload.monitor_id }), + ...(payload.manual_status === undefined + ? undefined + : { manualStatus: payload.manual_status }), + ...(payload.show_uptime === undefined + ? undefined + : { showUptime: payload.show_uptime }), + }) + yield* recordHttpAudit("status_page_component.updated", { + resourceId: updated.id, + changes: componentAuditDiff( + payload, + toV2StatusPageComponent(current), + toV2StatusPageComponent(updated), + ), + metadata: { name: updated.name, page_id: params.id }, + }) + return toV2StatusPageComponent(updated) + }), + ) + .handle("deleteComponent", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, adminOnly("edit")) + const current = yield* requireComponentOnPage( + tenant.orgId, + params.id, + params.component_id, + ) + const removed = yield* service.removeComponent(tenant.orgId, params.component_id) + yield* recordHttpAudit("status_page_component.deleted", { + resourceId: removed, + metadata: { name: current.name, page_id: params.id }, + }) + return { id: removed, object: "status_page_component" as const, deleted: true as const } + }), + ) + }), +) + +const byNewest = (a: StatusPageIncidentRecord, b: StatusPageIncidentRecord) => + b.startedAt - a.startedAt || (a.id < b.id ? 1 : -1) + +export const HttpV2StatusPageIncidentsLive = HttpApiBuilder.group( + MapleApiV2, + "statusPageIncidents", + (handlers) => + Effect.gen(function* () { + const service = yield* StatusPagesService + + return handlers + .handle("list", ({ query }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const state = query.state ?? "open" + const pageIds = + query.page_id === undefined + ? (yield* service.list(tenant.orgId)).map((page) => page.id) + : [query.page_id] + const incidents = yield* Effect.forEach( + pageIds, + (pageId) => + service.listIncidents(tenant.orgId, pageId, { + includeResolved: state !== "open", + }), + { concurrency: 4 }, + ) + // `resolvedAt` is set when an incident resolves or maintenance completes. + const filtered = incidents + .flat() + .filter((incident) => state !== "closed" || incident.resolvedAt !== null) + .sort(byNewest) + const page = yield* paginateArray(filtered.map(toV2StatusPageIncident), query) + return { object: "list" as const, ...page } + }), + ) + .handle("retrieve", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + return toV2StatusPageIncident(yield* service.getIncident(tenant.orgId, params.id)) + }), + ) + .handle("create", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const kind = payload.kind ?? "incident" + const created = yield* service.createIncident(tenant.orgId, payload.page_id, { + kind, + title: payload.title, + impact: payload.impact ?? DEFAULT_IMPACT[kind], + status: payload.status ?? DEFAULT_STATUS[kind], + body: payload.body, + componentIds: payload.component_ids ?? [], + ...(payload.published === undefined + ? undefined + : { published: payload.published }), + ...(payload.scheduled_start === undefined + ? undefined + : { scheduledStart: timestampMs(payload.scheduled_start) }), + ...(payload.scheduled_end === undefined + ? undefined + : { scheduledEnd: timestampMs(payload.scheduled_end) }), + source: "api", + createdBy: tenant.userId, + }) + yield* recordHttpAudit("status_page_incident.created", { + resourceId: created.id, + metadata: { + title: created.title, + kind: created.kind, + page_id: created.pageId, + published: created.published, + }, + }) + return toV2StatusPageIncident(created) + }), + ) + .handle("update", ({ params, payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const current = yield* service.getIncident(tenant.orgId, params.id) + const scheduledStart = parseTimestamp(payload.scheduled_start) + const scheduledEnd = parseTimestamp(payload.scheduled_end) + const updated = yield* service.updateIncident(tenant.orgId, params.id, { + ...(payload.title === undefined ? undefined : { title: payload.title }), + ...(payload.impact === undefined ? undefined : { impact: payload.impact }), + ...(payload.published === undefined + ? undefined + : { published: payload.published }), + ...(scheduledStart === undefined ? undefined : { scheduledStart }), + ...(scheduledEnd === undefined ? undefined : { scheduledEnd }), + ...(payload.component_ids === undefined + ? undefined + : { componentIds: payload.component_ids }), + }) + yield* recordHttpAudit("status_page_incident.updated", { + resourceId: updated.id, + changes: incidentAuditDiff( + payload, + toV2StatusPageIncident(current), + toV2StatusPageIncident(updated), + ), + metadata: { title: updated.title, page_id: updated.pageId }, + }) + return toV2StatusPageIncident(updated) + }), + ) + .handle("delete", ({ params }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const current = yield* service.getIncident(tenant.orgId, params.id) + const removed = yield* service.removeIncident(tenant.orgId, params.id) + yield* recordHttpAudit("status_page_incident.deleted", { + resourceId: removed, + metadata: { title: current.title, page_id: current.pageId }, + }) + return { + id: removed, + object: "status_page_incident" as const, + deleted: true as const, + } + }), + ) + .handle("postUpdate", ({ params, payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const updated = yield* service.postUpdate(tenant.orgId, params.id, { + status: payload.status, + body: payload.body, + ...(payload.impact === undefined ? undefined : { impact: payload.impact }), + ...(payload.component_ids === undefined + ? undefined + : { componentIds: payload.component_ids }), + source: "api", + createdBy: tenant.userId, + }) + yield* recordHttpAudit("status_page_incident.update_posted", { + resourceId: updated.id, + metadata: { + status: updated.status, + page_id: updated.pageId, + active: isActiveIncident(updated), + }, + }) + return toV2StatusPageIncident(updated) + }), + ) + }), +) diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts index 15798876f3..fbf8594e74 100644 --- a/apps/api/src/routes/v2/v2-test-support.ts +++ b/apps/api/src/routes/v2/v2-test-support.ts @@ -72,6 +72,8 @@ import { AuditLogService } from "@maple/backend/services/audit/AuditLogService" import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" import { HttpV2ScrapeTargetsLive } from "./scrape-targets.http" import { HttpV2UptimeMonitorsLive } from "./uptime-monitors.http" +import { HttpV2StatusPageIncidentsLive, HttpV2StatusPagesLive } from "./status-pages.http" +import { StatusPagesService } from "@maple/backend/services/status-pages/StatusPagesService" import { UptimeMonitorsService } from "@maple/backend/services/uptime/UptimeMonitorsService" import { HttpV2SessionReplaysLive } from "./session-replays.http" import { HttpV2InstrumentationAuditLive } from "./setup-audit.http" @@ -183,6 +185,10 @@ const v2GroupLayersExceptOnboardingChecklist = ( HttpV2AuditLogLive.pipe(Layer.provide(Layer.merge(auditLog, OrgMembersServiceStubLayer))), HttpV2ScrapeTargetsLive, HttpV2UptimeMonitorsLive.pipe(Layer.provide(uptimeMonitors)), + // Real service: it needs only the Database every harness already provides. + Layer.mergeAll(HttpV2StatusPagesLive, HttpV2StatusPageIncidentsLive).pipe( + Layer.provide(StatusPagesService.layer), + ), HttpV2InstrumentationRecommendationsLive, HttpV2InstrumentationAuditLive, HttpV2TelemetrySignalsLive, diff --git a/apps/api/src/runtime/http-graph.ts b/apps/api/src/runtime/http-graph.ts index 5081bedbac..20f1b1e669 100644 --- a/apps/api/src/runtime/http-graph.ts +++ b/apps/api/src/runtime/http-graph.ts @@ -66,6 +66,7 @@ import { HttpV2AuditLogLive } from "@/routes/v2/audit-log.http" import { AuditLogService } from "@maple/backend/services/audit/AuditLogService" import { HttpV2ScrapeTargetsLive } from "@/routes/v2/scrape-targets.http" import { HttpV2UptimeMonitorsLive } from "@/routes/v2/uptime-monitors.http" +import { HttpV2StatusPageIncidentsLive, HttpV2StatusPagesLive } from "@/routes/v2/status-pages.http" import { HttpV2InstrumentationAuditLive } from "@/routes/v2/setup-audit.http" import { HttpV2OnboardingChecklistLive } from "@/routes/v2/onboarding-checklist.http" import { HttpV2SupportChannelLive } from "@/routes/v2/support-channel.http" @@ -183,6 +184,8 @@ const ApiV2Routes = HttpApiBuilder.layer(MapleApiV2).pipe( HttpV2AuditLogLive, HttpV2ScrapeTargetsLive, HttpV2UptimeMonitorsLive, + HttpV2StatusPagesLive, + HttpV2StatusPageIncidentsLive, HttpV2InstrumentationRecommendationsLive, HttpV2InstrumentationAuditLive, HttpV2OnboardingChecklistLive, diff --git a/docs/api-v2.md b/docs/api-v2.md index 86aecbb323..8614fc9745 100644 --- a/docs/api-v2.md +++ b/docs/api-v2.md @@ -50,7 +50,7 @@ POST /v2/traces/search complex reads are POST .../search Every v2 object has a prefixed public ID (`key_4CzLmR…`, `dash_…`, `alrt_…`). Public IDs are opaque; internally they are a reversible base58 encoding of the internal ID, computed at the API boundary in `packages/domain/src/http/v2/public-id.ts`, which also holds the prefix registry (`PublicIdPrefixes`, the source of truth). No database migration: rows keep their raw UUIDs / internal strings. -Prefixes: `key` (API key), `ingk` (ingest key), `dash` (dashboard), `dbv` (dashboard version), `dshr` (dashboard share), `dtpl` (dashboard template), `alrt` (alert rule), `dest` (alert destination), `inc` (alert incident), `evt` (alert delivery event), `actor` (actor), `alog` (audit log entry), `einc` (error incident), `iss` (error issue), `inv` (investigation), `anom` (anomaly incident), `scrp` (scrape target), `rec` (recommendation), `amap` (attribute mapping), `srep` (session replay), `mdev` (mobile device), `chatw` (chat workspace), `afb` (agent feedback), `gcpc` (Google Cloud connector), `mcpc` (MCP OAuth connection), `dcred` (device credential), and `log` (synthetic log identity); `we` is reserved for webhooks. +Prefixes: `key` (API key), `ingk` (ingest key), `dash` (dashboard), `dbv` (dashboard version), `dshr` (dashboard share), `dtpl` (dashboard template), `alrt` (alert rule), `dest` (alert destination), `inc` (alert incident), `evt` (alert delivery event), `actor` (actor), `alog` (audit log entry), `einc` (error incident), `iss` (error issue), `inv` (investigation), `anom` (anomaly incident), `scrp` (scrape target), `upm` (uptime monitor), `stp` (status page), `stc` (status page component), `sti` (status page incident), `stu` (status page incident update), `rec` (recommendation), `amap` (attribute mapping), `srep` (session replay), `mdev` (mobile device), `chatw` (chat workspace), `afb` (agent feedback), `gcpc` (Google Cloud connector), `mcpc` (MCP OAuth connection), `dcred` (device credential), and `log` (synthetic log identity); `we` is reserved for webhooks. Exception: Clerk-issued `org_…` / `user_…` IDs are already prefixed public IDs and pass through unchanged. @@ -174,6 +174,8 @@ Implemented in phases; the pilot (`api_keys`) ships first and proves every conve | `instrumentation/audit` ✅ | retrieve (singleton report, recomputed per request) | `SetupAuditService` | | `onboarding/checklist` ✅ | retrieve + claim (singleton; steps verified per request, claim admin-only and idempotent) | `OnboardingChecklistService` | | `scrape_targets` ✅ | CRUD + `probe` + `checks` | `ScrapeTargetsService` | +| `status_pages` ✅ | CRUD + `publish` + break-glass `rotate_break_glass`/`DELETE …/break_glass` + `components` (CRUD + `reorder`) | `StatusPagesService` | +| `status_page_incidents` ✅ | CRUD (filter by `page_id`, `state`) + `POST …/updates` | `StatusPagesService` | | `attribute_mappings` ✅ | CRUD | `IngestAttributeMappingService` | | `ingest_rules` ✅ | CRUD + `reorder` + `stats`; writes admin-only. The gateway applies rules when `INGEST_RULES_ENABLED` is on (`docs/ingest-rules.md`) | `IngestRuleService` | | `integrations/chat_*` ✅ | connector list (manifest + linked workspaces) + admin-only install/settings/unlink; the chat platform is a value, never a path segment | `ChatWorkspaceService` | diff --git a/packages/domain/src/audit-actions.ts b/packages/domain/src/audit-actions.ts index 06a4a22f0c..b12d880405 100644 --- a/packages/domain/src/audit-actions.ts +++ b/packages/domain/src/audit-actions.ts @@ -119,6 +119,28 @@ export const AuditResources = { organization: { verbs: ["deleted", "region_chosen"] }, scrape_target: { prefix: PublicIdPrefixes.scrapeTarget, verbs: ["created", "updated", "deleted"] }, uptime_monitor: { prefix: PublicIdPrefixes.uptimeMonitor, verbs: ["created", "updated", "deleted"] }, + /** A public status page; component reorders and break-glass token changes are page-level. */ + status_page: { + prefix: PublicIdPrefixes.statusPage, + verbs: [ + "created", + "updated", + "deleted", + "published", + "components_reordered", + "break_glass_rotated", + "break_glass_revoked", + ], + }, + status_page_component: { + prefix: PublicIdPrefixes.statusPageComponent, + verbs: ["created", "updated", "deleted"], + }, + /** A customer-facing incident or maintenance; `update_posted` is a public update on its timeline. */ + status_page_incident: { + prefix: PublicIdPrefixes.statusPageIncident, + verbs: ["created", "updated", "deleted", "update_posted"], + }, /** A person's triage of a security audit finding; target, fingerprint, status and reason in `metadata`. */ security_audit_finding: { verbs: ["triaged"] }, /** An admin stopping an in-progress security audit run; target and run id in `metadata`. */ @@ -187,6 +209,9 @@ export const AuditResourceLabels = { organization: "Organization", scrape_target: "Scrape target", uptime_monitor: "Uptime monitor", + status_page: "Status page", + status_page_component: "Status page component", + status_page_incident: "Status page incident", security_audit_finding: "Security audit finding", security_audit_run: "Security audit run", security_audit_settings: "Security audit settings", @@ -292,6 +317,20 @@ export const AuditActionLabels = { "uptime_monitor.created": "Created uptime monitor", "uptime_monitor.updated": "Updated uptime monitor", "uptime_monitor.deleted": "Deleted uptime monitor", + "status_page.created": "Created status page", + "status_page.updated": "Updated status page", + "status_page.deleted": "Deleted status page", + "status_page.published": "Published status page", + "status_page.components_reordered": "Reordered status page components", + "status_page.break_glass_rotated": "Rotated status page break-glass token", + "status_page.break_glass_revoked": "Revoked status page break-glass token", + "status_page_component.created": "Created status page component", + "status_page_component.updated": "Updated status page component", + "status_page_component.deleted": "Deleted status page component", + "status_page_incident.created": "Created status page incident", + "status_page_incident.updated": "Updated status page incident", + "status_page_incident.deleted": "Deleted status page incident", + "status_page_incident.update_posted": "Posted status page update", "security_audit_finding.triaged": "Triaged security audit finding", "security_audit_run.cancelled": "Cancelled security audit run", "security_audit_settings.updated": "Updated security audit spend limit and models", diff --git a/packages/domain/src/http/v2/api.ts b/packages/domain/src/http/v2/api.ts index d0c4e5cba2..61e254ffed 100644 --- a/packages/domain/src/http/v2/api.ts +++ b/packages/domain/src/http/v2/api.ts @@ -27,6 +27,7 @@ import { V2InstrumentationRecommendationsApiGroup } from "./recommendations" import { V2ScrapeTargetsApiGroup } from "./scrape-targets" import { V2SessionReplaysApiGroup } from "./session-replays" import { V2UptimeMonitorsApiGroup } from "./uptime-monitors" +import { V2StatusPageIncidentsApiGroup, V2StatusPagesApiGroup } from "./status-pages" import { V2InstrumentationAuditApiGroup } from "./setup-audit" import { V2SupportChannelApiGroup } from "./support-channel" import { V2TelemetrySignalsApiGroup } from "./telemetry-signals" @@ -113,6 +114,8 @@ export class MapleApiV2 extends HttpApi.make("MapleApiV2") .add(V2AuditLogApiGroup) .add(V2ScrapeTargetsApiGroup) .add(V2UptimeMonitorsApiGroup) + .add(V2StatusPagesApiGroup) + .add(V2StatusPageIncidentsApiGroup) .add(V2InstrumentationRecommendationsApiGroup) .add(V2InstrumentationAuditApiGroup) .add(V2TelemetrySignalsApiGroup) diff --git a/packages/domain/src/http/v2/index.ts b/packages/domain/src/http/v2/index.ts index 81dddb956b..448f2ee9fa 100644 --- a/packages/domain/src/http/v2/index.ts +++ b/packages/domain/src/http/v2/index.ts @@ -33,6 +33,7 @@ export * from "./route-not-found" export * from "./scrape-targets" export * from "./session-replays" export * from "./uptime-monitors" +export * from "./status-pages" export * from "./onboarding-checklist" export * from "./setup-audit" export * from "./support-channel" diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index cf8b83d825..0bcc6762e4 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -95,6 +95,10 @@ describe("MapleApiV2 OpenAPI", () => { "DELETE /v2/mobile_devices/{token}/live_activities/{incident_id}", "DELETE /v2/scrape_targets/{id}", "DELETE /v2/sources/{id}", + "DELETE /v2/status_page_incidents/{id}", + "DELETE /v2/status_pages/{id}", + "DELETE /v2/status_pages/{id}/break_glass", + "DELETE /v2/status_pages/{id}/components/{component_id}", "DELETE /v2/uptime_monitors/{id}", "DELETE /v2/widget_credentials/{installation_id}", "GET /v2/agent_feedback", @@ -170,6 +174,11 @@ describe("MapleApiV2 OpenAPI", () => { "GET /v2/sources/usage", "GET /v2/sources/{id}", "GET /v2/sources/{id}/usage", + "GET /v2/status_page_incidents", + "GET /v2/status_page_incidents/{id}", + "GET /v2/status_pages", + "GET /v2/status_pages/{id}", + "GET /v2/status_pages/{id}/components", "GET /v2/support/slack_channel", "GET /v2/traces/{trace_id}", "GET /v2/traces/{trace_id}/spans/{span_id}", @@ -187,6 +196,9 @@ describe("MapleApiV2 OpenAPI", () => { "PATCH /v2/integrations/gcp/connectors/{id}", "PATCH /v2/scrape_targets/{id}", "PATCH /v2/sources/{id}", + "PATCH /v2/status_page_incidents/{id}", + "PATCH /v2/status_pages/{id}", + "PATCH /v2/status_pages/{id}/components/{component_id}", "PATCH /v2/uptime_monitors/{id}", "POST /v2/agent_feedback", "POST /v2/alerts/destinations", @@ -253,6 +265,13 @@ describe("MapleApiV2 OpenAPI", () => { "POST /v2/sources/{id}/pause", "POST /v2/sources/{id}/resume", "POST /v2/sources/{id}/roll", + "POST /v2/status_page_incidents", + "POST /v2/status_page_incidents/{id}/updates", + "POST /v2/status_pages", + "POST /v2/status_pages/{id}/components", + "POST /v2/status_pages/{id}/components/reorder", + "POST /v2/status_pages/{id}/publish", + "POST /v2/status_pages/{id}/rotate_break_glass", "POST /v2/support/slack_channel/invite", "POST /v2/traces/breakdown", "POST /v2/traces/search", diff --git a/packages/domain/src/http/v2/public-id.ts b/packages/domain/src/http/v2/public-id.ts index b2970c921e..b0b9d5b5af 100644 --- a/packages/domain/src/http/v2/public-id.ts +++ b/packages/domain/src/http/v2/public-id.ts @@ -35,6 +35,10 @@ export const PublicIdPrefixes = { anomalyIncident: "anom", scrapeTarget: "scrp", uptimeMonitor: "upm", + statusPage: "stp", + statusPageComponent: "stc", + statusPageIncident: "sti", + statusPageIncidentUpdate: "stu", recommendation: "rec", ingestKey: "ingk", source: "src", diff --git a/packages/domain/src/http/v2/status-pages.ts b/packages/domain/src/http/v2/status-pages.ts new file mode 100644 index 0000000000..6d7d51cf30 --- /dev/null +++ b/packages/domain/src/http/v2/status-pages.ts @@ -0,0 +1,816 @@ +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api" +import { Schema } from "effect" +import { + StatusPageAutoIncidents, + StatusPageComponentId, + StatusPageComponentStatus, + StatusPageId, + StatusPageIncidentId, + StatusPageIncidentImpact, + StatusPageIncidentKind, + StatusPageIncidentStatus, + StatusPageIncidentUpdateId, + StatusPageUpdateSource, +} from "../../primitives" +import { + StatusPageComponentNotFoundError, + StatusPageIncidentNotFoundError, + StatusPageNotFoundError, + StatusPagePersistenceError, + StatusPageSlugTakenError, + StatusPageValidationError, +} from "../status-pages" +import { AuthorizationV2 } from "./auth" +import { ListOf, ListQuery, Timestamp, wireExample } from "./envelopes" +import { V2InsufficientPermissions, V2ParameterInvalid } from "./errors" +import { publicErrors } from "./public-error" +import { PublicId, PublicIdPrefixes } from "./public-id" +import { UptimeMonitorPublicId } from "./uptime-monitors" + +/** `stp_…` public ID ⇄ internal `StatusPageId` (raw UUID). */ +export const StatusPagePublicId = PublicId(PublicIdPrefixes.statusPage, StatusPageId) +/** `stc_…` public ID ⇄ internal `StatusPageComponentId`. */ +export const StatusPageComponentPublicId = PublicId( + PublicIdPrefixes.statusPageComponent, + StatusPageComponentId, +) +/** `sti_…` public ID ⇄ internal `StatusPageIncidentId`. */ +export const StatusPageIncidentPublicId = PublicId(PublicIdPrefixes.statusPageIncident, StatusPageIncidentId) +/** `stu_…` public ID ⇄ internal `StatusPageIncidentUpdateId`. */ +export const StatusPageIncidentUpdatePublicId = PublicId( + PublicIdPrefixes.statusPageIncidentUpdate, + StatusPageIncidentUpdateId, +) + +const NonEmptyString = Schema.String.check(Schema.isMinLength(1)) + +// --- Pages --- + +const slugField = Schema.String.annotate({ + description: + "The page's address, `.`: 3 to 48 lowercase letters, digits or inner hyphens. Unique across Maple.", + examples: ["acme-status"], +}) + +const autoIncidentsField = StatusPageAutoIncidents.annotate({ + description: + "Whether a linked monitor going down opens a page incident: `off`, `draft` (unpublished until you publish it), or `publish`.", + examples: ["draft"], +}) + +const statusPageExample = { + id: "stp_YofPTrK9782DWwcnXhpcCw", + object: "status_page", + name: "Acme Status", + slug: "acme-status", + description: "Live status of Acme's API and dashboard.", + website_url: "https://acme.example", + logo_url: null, + brand_color: "#1a2b3c", + show_powered_by: true, + auto_incidents: "draft", + break_glass_token_suffix: null, + revision: 4, + published_revision: 4, + published_at: "2026-07-15T09:12:00.000Z", + last_publish_error: null, + created_at: "2026-07-01T12:00:00.000Z", + updated_at: "2026-07-15T09:12:00.000Z", +} as const + +export const V2StatusPage = Schema.Struct({ + id: StatusPagePublicId, + object: Schema.Literal("status_page").annotate({ + description: 'The object type, always `"status_page"`.', + examples: ["status_page"], + }), + name: Schema.String.annotate({ description: "Shown as the page title.", examples: ["Acme Status"] }), + slug: slugField, + description: Schema.NullOr(Schema.String).annotate({ description: "Text under the title, or `null`." }), + website_url: Schema.NullOr(Schema.String).annotate({ description: "Your site, linked from the page." }), + logo_url: Schema.NullOr(Schema.String).annotate({ description: "An https logo URL, or `null`." }), + brand_color: Schema.NullOr(Schema.String).annotate({ + description: "Accent color as `#rrggbb`, or `null`.", + examples: ["#1a2b3c"], + }), + show_powered_by: Schema.Boolean.annotate({ description: "Whether the page credits Maple." }), + auto_incidents: autoIncidentsField, + break_glass_token_suffix: Schema.NullOr(Schema.String).annotate({ + description: "Last 6 characters of the break-glass token, or `null` when none is issued.", + }), + revision: Schema.Number.annotate({ description: "Bumped by every change a visitor could see." }), + published_revision: Schema.Number.annotate({ + description: "The revision now served. Behind `revision` until the next publish.", + }), + published_at: Schema.NullOr(Timestamp).annotate({ + description: "When the page was last published, or `null` before the first publish.", + }), + last_publish_error: Schema.NullOr(Schema.String).annotate({ + description: "Why the last publish failed, or `null` when it succeeded.", + }), + created_at: Timestamp, + updated_at: Timestamp, +}).annotate({ + identifier: "StatusPage", + title: "Status Page", + description: + "A public status page. Maple publishes it to edge storage, so it stays up when Maple does not.", + examples: [wireExample(statusPageExample)], +}) +export type V2StatusPage = Schema.Schema.Type + +export const V2StatusPageCreateParams = Schema.Struct({ + name: NonEmptyString.annotate({ description: "Page title. Required.", examples: ["Acme Status"] }), + slug: slugField, + description: Schema.optionalKey(Schema.NullOr(Schema.String)), + website_url: Schema.optionalKey(Schema.NullOr(Schema.String)), + logo_url: Schema.optionalKey(Schema.NullOr(Schema.String)), + brand_color: Schema.optionalKey(Schema.NullOr(Schema.String)), + show_powered_by: Schema.optionalKey(Schema.Boolean), + auto_incidents: Schema.optionalKey(autoIncidentsField), +}).annotate({ + identifier: "StatusPageCreateParams", + title: "Status page create parameters", + description: "Request body for creating a status page.", + examples: [wireExample({ name: "Acme Status", slug: "acme-status" })], +}) +export type V2StatusPageCreateParams = Schema.Schema.Type + +export const V2StatusPageUpdateParams = Schema.Struct({ + name: Schema.optionalKey(NonEmptyString), + slug: Schema.optionalKey(slugField), + description: Schema.optionalKey(Schema.NullOr(Schema.String)), + website_url: Schema.optionalKey(Schema.NullOr(Schema.String)), + logo_url: Schema.optionalKey(Schema.NullOr(Schema.String)), + brand_color: Schema.optionalKey(Schema.NullOr(Schema.String)), + show_powered_by: Schema.optionalKey(Schema.Boolean), + auto_incidents: Schema.optionalKey(autoIncidentsField), +}).annotate({ + identifier: "StatusPageUpdateParams", + title: "Status page update parameters", + description: "Request body for updating a status page. Omitted fields are left unchanged.", + examples: [wireExample({ brand_color: "#1a2b3c" })], +}) +export type V2StatusPageUpdateParams = Schema.Schema.Type + +export const V2StatusPageDeleteResponse = Schema.Struct({ + id: StatusPagePublicId, + object: Schema.Literal("status_page"), + deleted: Schema.Literal(true), +}).annotate({ + identifier: "StatusPageDeleteResponse", + title: "Status page delete response", + description: "Confirmation that a status page was deleted.", + examples: [wireExample({ id: "stp_YofPTrK9782DWwcnXhpcCw", object: "status_page", deleted: true })], +}) + +export const V2StatusPagePublishResult = Schema.Struct({ + id: StatusPagePublicId, + object: Schema.Literal("status_page.publish_result"), + outcome: Schema.Literals(["published", "unchanged", "queued"]).annotate({ + description: + "`published` wrote a new snapshot, `unchanged` found nothing visible to change, `queued` leaves it to the next minutely publish.", + }), + revision: Schema.Number.annotate({ description: "The page revision this publish covered." }), +}).annotate({ + identifier: "StatusPagePublishResult", + title: "Status page publish result", + description: "What a publish request did.", + examples: [ + wireExample({ + id: "stp_YofPTrK9782DWwcnXhpcCw", + object: "status_page.publish_result", + outcome: "published", + revision: 4, + }), + ], +}) +export type V2StatusPagePublishResult = Schema.Schema.Type + +export const V2StatusPageBreakGlassToken = Schema.Struct({ + id: StatusPagePublicId, + object: Schema.Literal("status_page.break_glass_token"), + token: Schema.String.annotate({ + description: + "Posts updates straight to the status page while Maple is unreachable. Shown once; store it outside Maple.", + }), + token_suffix: Schema.String.annotate({ description: "The last 6 characters, shown on the page later." }), +}).annotate({ + identifier: "StatusPageBreakGlassToken", + title: "Status page break-glass token", + description: "A newly issued break-glass token. Issuing one replaces any previous token.", + examples: [ + wireExample({ + id: "stp_YofPTrK9782DWwcnXhpcCw", + object: "status_page.break_glass_token", + token: "mstat_Qm9vZ2xlLWJyZWFrLWdsYXNzLXRva2VuLWV4YW1wbGU", + token_suffix: "WV4YW1", + }), + ], +}) +export type V2StatusPageBreakGlassToken = Schema.Schema.Type + +// --- Components --- + +const componentStatusField = StatusPageComponentStatus.annotate({ + description: + "A manual status override: `operational`, `maintenance`, `degraded`, `partial_outage` or `major_outage`.", +}) + +export const V2StatusPageComponent = Schema.Struct({ + id: StatusPageComponentPublicId, + object: Schema.Literal("status_page_component").annotate({ + description: 'The object type, always `"status_page_component"`.', + examples: ["status_page_component"], + }), + page_id: StatusPagePublicId, + name: Schema.String.annotate({ examples: ["API"] }), + description: Schema.NullOr(Schema.String), + group: Schema.NullOr(Schema.String).annotate({ description: "Heading the component is listed under." }), + position: Schema.Number.annotate({ description: "Order on the page, from 0." }), + monitor_id: Schema.NullOr(UptimeMonitorPublicId).annotate({ + description: "The uptime monitor driving its status and uptime bars, or `null`.", + }), + manual_status: Schema.NullOr(componentStatusField), + show_uptime: Schema.Boolean.annotate({ + description: "Whether the page shows its 90 days of uptime bars.", + }), + created_at: Timestamp, + updated_at: Timestamp, +}).annotate({ + identifier: "StatusPageComponent", + title: "Status Page Component", + description: + "One line on a status page. Its status is the worst of its monitor, the manual override, and active incidents naming it.", + examples: [ + wireExample({ + id: "stc_YofPTrK9782DWwcnXhpcCw", + object: "status_page_component", + page_id: "stp_YofPTrK9782DWwcnXhpcCw", + name: "API", + description: null, + group: null, + position: 0, + monitor_id: "upm_YofPTrK9782DWwcnXhpcCw", + manual_status: null, + show_uptime: true, + created_at: "2026-07-01T12:00:00.000Z", + updated_at: "2026-07-01T12:00:00.000Z", + }), + ], +}) +export type V2StatusPageComponent = Schema.Schema.Type + +export const V2StatusPageComponentCreateParams = Schema.Struct({ + name: NonEmptyString.annotate({ examples: ["API"] }), + description: Schema.optionalKey(Schema.NullOr(Schema.String)), + group: Schema.optionalKey(Schema.NullOr(Schema.String)), + monitor_id: Schema.optionalKey(Schema.NullOr(UptimeMonitorPublicId)), + manual_status: Schema.optionalKey(Schema.NullOr(componentStatusField)), + show_uptime: Schema.optionalKey(Schema.Boolean), +}).annotate({ + identifier: "StatusPageComponentCreateParams", + title: "Status page component create parameters", + description: "Request body for adding a component; it is appended after the existing ones.", + examples: [wireExample({ name: "API", monitor_id: "upm_YofPTrK9782DWwcnXhpcCw" })], +}) +export type V2StatusPageComponentCreateParams = Schema.Schema.Type + +export const V2StatusPageComponentUpdateParams = Schema.Struct({ + name: Schema.optionalKey(NonEmptyString), + description: Schema.optionalKey(Schema.NullOr(Schema.String)), + group: Schema.optionalKey(Schema.NullOr(Schema.String)), + monitor_id: Schema.optionalKey(Schema.NullOr(UptimeMonitorPublicId)), + manual_status: Schema.optionalKey(Schema.NullOr(componentStatusField)), + show_uptime: Schema.optionalKey(Schema.Boolean), +}).annotate({ + identifier: "StatusPageComponentUpdateParams", + title: "Status page component update parameters", + description: "Request body for updating a component. Omitted fields are left unchanged.", + examples: [wireExample({ manual_status: "degraded" })], +}) +export type V2StatusPageComponentUpdateParams = Schema.Schema.Type + +export const V2StatusPageComponentDeleteResponse = Schema.Struct({ + id: StatusPageComponentPublicId, + object: Schema.Literal("status_page_component"), + deleted: Schema.Literal(true), +}).annotate({ + identifier: "StatusPageComponentDeleteResponse", + title: "Status page component delete response", + description: "Confirmation that a component was removed.", + examples: [ + wireExample({ id: "stc_YofPTrK9782DWwcnXhpcCw", object: "status_page_component", deleted: true }), + ], +}) + +export const V2StatusPageComponentReorderParams = Schema.Struct({ + component_ids: Schema.Array(StatusPageComponentPublicId).annotate({ + description: "Every component of the page exactly once, in the new order.", + }), +}).annotate({ + identifier: "StatusPageComponentReorderParams", + title: "Status page component reorder parameters", + description: "The page's components in their new order.", + examples: [wireExample({ component_ids: ["stc_YofPTrK9782DWwcnXhpcCw"] })], +}) + +const StatusPageComponentList = ListOf(V2StatusPageComponent).annotate({ + identifier: "StatusPageComponentList", + title: "Status page component list", + description: "A page's components in display order.", +}) + +// --- Incidents --- + +export const V2StatusPageIncidentUpdate = Schema.Struct({ + id: StatusPageIncidentUpdatePublicId, + status: StatusPageIncidentStatus, + body: Schema.String.annotate({ description: "The public message." }), + source: StatusPageUpdateSource.annotate({ + description: "Where it was posted from: `dashboard`, `api`, `mcp`, `auto` or `break_glass`.", + }), + created_at: Timestamp, +}).annotate({ identifier: "StatusPageIncidentUpdate", title: "Status page incident update" }) + +const scheduledField = Timestamp.annotate({ + description: "Maintenance only: the scheduled window bound.", +}) + +export const V2StatusPageIncident = Schema.Struct({ + id: StatusPageIncidentPublicId, + object: Schema.Literal("status_page_incident").annotate({ + description: 'The object type, always `"status_page_incident"`.', + examples: ["status_page_incident"], + }), + page_id: StatusPagePublicId, + kind: StatusPageIncidentKind.annotate({ description: "`incident` or scheduled `maintenance`." }), + title: Schema.String, + impact: StatusPageIncidentImpact.annotate({ + description: "`none`, `minor`, `major` or `critical`: how hard it hits the components it names.", + }), + status: StatusPageIncidentStatus.annotate({ + description: + "Incidents: `investigating`, `identified`, `monitoring`, `resolved`. Maintenance: `scheduled`, `in_progress`, `completed`.", + }), + published: Schema.Boolean.annotate({ description: "Drafts (`false`) are not shown on the page." }), + scheduled_start: Schema.NullOr(scheduledField), + scheduled_end: Schema.NullOr(scheduledField), + started_at: Timestamp, + resolved_at: Schema.NullOr(Timestamp), + component_ids: Schema.Array(StatusPageComponentPublicId), + updates: Schema.Array(V2StatusPageIncidentUpdate).annotate({ description: "Newest first." }), + created_at: Timestamp, + updated_at: Timestamp, +}).annotate({ + identifier: "StatusPageIncident", + title: "Status Page Incident", + description: "A customer-facing incident or scheduled maintenance, with its public updates.", + examples: [ + wireExample({ + id: "sti_YofPTrK9782DWwcnXhpcCw", + object: "status_page_incident", + page_id: "stp_YofPTrK9782DWwcnXhpcCw", + kind: "incident", + title: "Elevated API errors", + impact: "minor", + status: "investigating", + published: true, + scheduled_start: null, + scheduled_end: null, + started_at: "2026-07-15T09:12:00.000Z", + resolved_at: null, + component_ids: ["stc_YofPTrK9782DWwcnXhpcCw"], + updates: [ + { + id: "stu_YofPTrK9782DWwcnXhpcCw", + status: "investigating", + body: "We are looking into elevated error rates.", + source: "api", + created_at: "2026-07-15T09:12:00.000Z", + }, + ], + created_at: "2026-07-15T09:12:00.000Z", + updated_at: "2026-07-15T09:12:00.000Z", + }), + ], +}) +export type V2StatusPageIncident = Schema.Schema.Type + +export const V2StatusPageIncidentCreateParams = Schema.Struct({ + page_id: StatusPagePublicId, + kind: Schema.optionalKey(StatusPageIncidentKind.annotate({ description: "Defaults to `incident`." })), + title: NonEmptyString.annotate({ examples: ["Elevated API errors"] }), + impact: Schema.optionalKey( + StatusPageIncidentImpact.annotate({ + description: "Defaults to `minor` for an incident and `none` for maintenance.", + }), + ), + status: Schema.optionalKey( + StatusPageIncidentStatus.annotate({ + description: "Defaults to `investigating` for an incident and `scheduled` for maintenance.", + }), + ), + body: NonEmptyString.annotate({ description: "The first public update." }), + component_ids: Schema.optionalKey(Schema.Array(StatusPageComponentPublicId)), + published: Schema.optionalKey( + Schema.Boolean.annotate({ description: "`false` saves a draft. Defaults to `true`." }), + ), + scheduled_start: Schema.optionalKey(scheduledField), + scheduled_end: Schema.optionalKey(scheduledField), +}).annotate({ + identifier: "StatusPageIncidentCreateParams", + title: "Status page incident create parameters", + description: "Request body for opening an incident or scheduling maintenance.", + examples: [ + wireExample({ + page_id: "stp_YofPTrK9782DWwcnXhpcCw", + title: "Elevated API errors", + body: "We are looking into elevated error rates.", + }), + ], +}) +export type V2StatusPageIncidentCreateParams = Schema.Schema.Type + +export const V2StatusPageIncidentUpdateParams = Schema.Struct({ + title: Schema.optionalKey(NonEmptyString), + impact: Schema.optionalKey(StatusPageIncidentImpact), + published: Schema.optionalKey(Schema.Boolean), + scheduled_start: Schema.optionalKey(Schema.NullOr(scheduledField)), + scheduled_end: Schema.optionalKey(Schema.NullOr(scheduledField)), + component_ids: Schema.optionalKey(Schema.Array(StatusPageComponentPublicId)), +}).annotate({ + identifier: "StatusPageIncidentUpdateParams", + title: "Status page incident update parameters", + description: + "Request body for editing an incident without posting an update. Omitted fields are left unchanged.", + examples: [wireExample({ published: true })], +}) +export type V2StatusPageIncidentUpdateParams = Schema.Schema.Type + +export const V2StatusPageIncidentPostUpdateParams = Schema.Struct({ + status: StatusPageIncidentStatus, + body: NonEmptyString.annotate({ description: "The public message." }), + impact: Schema.optionalKey(StatusPageIncidentImpact), + component_ids: Schema.optionalKey(Schema.Array(StatusPageComponentPublicId)), +}).annotate({ + identifier: "StatusPageIncidentPostUpdateParams", + title: "Status page incident post-update parameters", + description: "A public update; `resolved` or `completed` closes the incident.", + examples: [wireExample({ status: "resolved", body: "Error rates are back to normal." })], +}) +export type V2StatusPageIncidentPostUpdateParams = Schema.Schema.Type< + typeof V2StatusPageIncidentPostUpdateParams +> + +export const V2StatusPageIncidentDeleteResponse = Schema.Struct({ + id: StatusPageIncidentPublicId, + object: Schema.Literal("status_page_incident"), + deleted: Schema.Literal(true), +}).annotate({ + identifier: "StatusPageIncidentDeleteResponse", + title: "Status page incident delete response", + description: "Confirmation that an incident was deleted.", + examples: [ + wireExample({ id: "sti_YofPTrK9782DWwcnXhpcCw", object: "status_page_incident", deleted: true }), + ], +}) + +const IncidentsQuery = Schema.Struct({ + ...ListQuery.fields, + page_id: Schema.optional( + StatusPagePublicId.annotate({ description: "Only this page's incidents (`stp_…`)." }), + ), + state: Schema.optional( + Schema.Literals(["open", "closed", "all"]).annotate({ + description: "`open` (the default) is unresolved incidents and maintenance not yet completed.", + examples: ["open"], + }), + ), +}).annotate({ + identifier: "StatusPageIncidentListQuery", + title: "Status page incident list query", + description: "Pagination plus optional page and state filters.", +}) + +const [pageNotFound, componentNotFound, incidentNotFound, validation, slugTaken, persistence] = publicErrors( + StatusPageNotFoundError, + StatusPageComponentNotFoundError, + StatusPageIncidentNotFoundError, + StatusPageValidationError, + StatusPageSlugTakenError, + StatusPagePersistenceError, +) + +const StatusPageList = ListOf(V2StatusPage).annotate({ + identifier: "StatusPageList", + title: "Status page list", + description: "A cursor-paginated page of status pages.", +}) + +const StatusPageIncidentList = ListOf(V2StatusPageIncident).annotate({ + identifier: "StatusPageIncidentList", + title: "Status page incident list", + description: "A cursor-paginated page of status page incidents, newest first.", +}) + +const pageParams = { id: StatusPagePublicId } +const componentParams = { id: StatusPagePublicId, component_id: StatusPageComponentPublicId } +const adminNote = "Requires the `status_pages:write` scope and an org-admin caller." + +export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") + .add( + HttpApiEndpoint.get("list", "/", { + query: ListQuery, + success: StatusPageList, + error: [V2ParameterInvalid.schema, persistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "listStatusPages", + summary: "List status pages", + description: + "Returns your organization's status pages, oldest first. Cursor-paginated. Requires the `status_pages:read` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.post("create", "/", { + payload: V2StatusPageCreateParams, + success: V2StatusPage, + error: [V2InsufficientPermissions.schema, validation, slugTaken, persistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "createStatusPage", + summary: "Create a status page", + description: `Creates a status page; it is published within a minute. ${adminNote}`, + }), + ), + ) + .add( + HttpApiEndpoint.get("retrieve", "/:id", { + params: pageParams, + success: V2StatusPage, + error: [pageNotFound, persistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "getStatusPage", + summary: "Retrieve a status page", + description: + "Returns a status page by its `stp_…` ID. Requires the `status_pages:read` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.patch("update", "/:id", { + params: pageParams, + payload: V2StatusPageUpdateParams, + success: V2StatusPage, + error: [V2InsufficientPermissions.schema, pageNotFound, validation, slugTaken, persistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "updateStatusPage", + summary: "Update a status page", + description: `Updates a status page; omitted fields are unchanged. ${adminNote}`, + }), + ), + ) + .add( + HttpApiEndpoint.delete("delete", "/:id", { + params: pageParams, + success: V2StatusPageDeleteResponse, + error: [V2InsufficientPermissions.schema, pageNotFound, persistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "deleteStatusPage", + summary: "Delete a status page", + description: `Deletes a status page with its components and incidents, and takes it offline. ${adminNote}`, + }), + ), + ) + .add( + HttpApiEndpoint.post("publish", "/:id/publish", { + params: pageParams, + success: V2StatusPagePublishResult, + error: [V2InsufficientPermissions.schema, pageNotFound, persistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "publishStatusPage", + summary: "Publish a status page", + description: `Publishes the page now instead of within the minute. ${adminNote}`, + }), + ), + ) + .add( + HttpApiEndpoint.post("rotateBreakGlass", "/:id/rotate_break_glass", { + params: pageParams, + success: V2StatusPageBreakGlassToken, + error: [V2InsufficientPermissions.schema, pageNotFound, persistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "rotateStatusPageBreakGlassToken", + summary: "Issue a break-glass token", + description: `Issues a new break-glass token, replacing any previous one, and returns it once. ${adminNote}`, + }), + ), + ) + .add( + HttpApiEndpoint.delete("revokeBreakGlass", "/:id/break_glass", { + params: pageParams, + success: V2StatusPage, + error: [V2InsufficientPermissions.schema, pageNotFound, persistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "revokeStatusPageBreakGlassToken", + summary: "Revoke the break-glass token", + description: `Revokes the page's break-glass token. ${adminNote}`, + }), + ), + ) + .add( + HttpApiEndpoint.get("listComponents", "/:id/components", { + params: pageParams, + query: ListQuery, + success: StatusPageComponentList, + error: [V2ParameterInvalid.schema, pageNotFound, persistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "listStatusPageComponents", + summary: "List status page components", + description: + "Returns a page's components in display order. Requires the `status_pages:read` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.post("createComponent", "/:id/components", { + params: pageParams, + payload: V2StatusPageComponentCreateParams, + success: V2StatusPageComponent, + error: [V2InsufficientPermissions.schema, pageNotFound, validation, persistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "createStatusPageComponent", + summary: "Add a status page component", + description: `Adds a component after the existing ones. ${adminNote}`, + }), + ), + ) + .add( + HttpApiEndpoint.post("reorderComponents", "/:id/components/reorder", { + params: pageParams, + payload: V2StatusPageComponentReorderParams, + success: StatusPageComponentList, + error: [V2InsufficientPermissions.schema, pageNotFound, validation, persistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "reorderStatusPageComponents", + summary: "Reorder status page components", + description: `Sets the display order; the list must name every component once. ${adminNote}`, + }), + ), + ) + .add( + HttpApiEndpoint.patch("updateComponent", "/:id/components/:component_id", { + params: componentParams, + payload: V2StatusPageComponentUpdateParams, + success: V2StatusPageComponent, + error: [ + V2InsufficientPermissions.schema, + pageNotFound, + componentNotFound, + validation, + persistence, + ], + }).annotateMerge( + OpenApi.annotations({ + identifier: "updateStatusPageComponent", + summary: "Update a status page component", + description: `Updates a component; omitted fields are unchanged. ${adminNote}`, + }), + ), + ) + .add( + HttpApiEndpoint.delete("deleteComponent", "/:id/components/:component_id", { + params: componentParams, + success: V2StatusPageComponentDeleteResponse, + error: [ + V2InsufficientPermissions.schema, + pageNotFound, + componentNotFound, + validation, + persistence, + ], + }).annotateMerge( + OpenApi.annotations({ + identifier: "deleteStatusPageComponent", + summary: "Remove a status page component", + description: `Removes a component from its page. ${adminNote}`, + }), + ), + ) + .prefix("/v2/status_pages") + .middleware(AuthorizationV2) + .annotateMerge( + OpenApi.annotations({ + title: "Status Pages", + description: + "Public status pages for your customers, served from edge storage so they stay up when Maple does not. Components follow your uptime monitors.", + }), + ) {} + +export class V2StatusPageIncidentsApiGroup extends HttpApiGroup.make("statusPageIncidents") + .add( + HttpApiEndpoint.get("list", "/", { + query: IncidentsQuery, + success: StatusPageIncidentList, + error: [V2ParameterInvalid.schema, pageNotFound, persistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "listStatusPageIncidents", + summary: "List status page incidents", + description: + "Returns incidents and maintenance across your pages or one page, newest first. Requires the `status_page_incidents:read` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.post("create", "/", { + payload: V2StatusPageIncidentCreateParams, + success: V2StatusPageIncident, + error: [pageNotFound, validation, persistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "createStatusPageIncident", + summary: "Open a status page incident", + description: + "Opens an incident or schedules maintenance with its first public update. Published unless `published` is `false`. Requires the `status_page_incidents:write` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.get("retrieve", "/:id", { + params: { id: StatusPageIncidentPublicId }, + success: V2StatusPageIncident, + error: [incidentNotFound, persistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "getStatusPageIncident", + summary: "Retrieve a status page incident", + description: + "Returns an incident with its updates. Requires the `status_page_incidents:read` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.patch("update", "/:id", { + params: { id: StatusPageIncidentPublicId }, + payload: V2StatusPageIncidentUpdateParams, + success: V2StatusPageIncident, + error: [incidentNotFound, pageNotFound, validation, persistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "updateStatusPageIncident", + summary: "Update a status page incident", + description: + "Edits the title, impact, components, window or published flag without posting an update. Requires the `status_page_incidents:write` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("delete", "/:id", { + params: { id: StatusPageIncidentPublicId }, + success: V2StatusPageIncidentDeleteResponse, + error: [incidentNotFound, pageNotFound, validation, persistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "deleteStatusPageIncident", + summary: "Delete a status page incident", + description: + "Deletes an incident and its updates from the page. Requires the `status_page_incidents:write` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.post("postUpdate", "/:id/updates", { + params: { id: StatusPageIncidentPublicId }, + payload: V2StatusPageIncidentPostUpdateParams, + success: V2StatusPageIncident, + error: [incidentNotFound, pageNotFound, validation, persistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "postStatusPageIncidentUpdate", + summary: "Post a status page update", + description: + "Posts a public update and moves the incident to its status; `resolved` or `completed` closes it. Requires the `status_page_incidents:write` scope.", + }), + ), + ) + .prefix("/v2/status_page_incidents") + .middleware(AuthorizationV2) + .annotateMerge( + OpenApi.annotations({ + title: "Status Page Incidents", + description: + "Customer-facing incidents and scheduled maintenance on your status pages, separate from internal alert incidents.", + }), + ) {} From b12f6bdda2f27af2422442a5eb9dd10b2d67acee Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 28 Oct 2026 16:17:45 +0100 Subject: [PATCH 02/10] feat(status-pages): MCP tools for pages, incidents and maintenance --- apps/ai/src/mcp/dispatcher.ts | 4 + apps/ai/src/mcp/resources/instructions.ts | 1 + .../tools/__tests__/status-page-tools.test.ts | 152 +++++ apps/ai/src/mcp/tools/mutating.ts | 5 + apps/ai/src/mcp/tools/registry.ts | 2 + apps/ai/src/mcp/tools/runtime-requirements.ts | 4 + apps/ai/src/mcp/tools/status-pages.ts | 571 ++++++++++++++++++ .../test/mcp/unimplemented-tool-services.ts | 4 + packages/domain/src/mcp-outputs/catalog.ts | 7 + packages/domain/src/mcp-outputs/index.ts | 1 + .../domain/src/mcp-outputs/status-pages.ts | 92 +++ 11 files changed, 843 insertions(+) create mode 100644 apps/ai/src/mcp/tools/__tests__/status-page-tools.test.ts create mode 100644 apps/ai/src/mcp/tools/status-pages.ts create mode 100644 packages/domain/src/mcp-outputs/status-pages.ts diff --git a/apps/ai/src/mcp/dispatcher.ts b/apps/ai/src/mcp/dispatcher.ts index 411cf0df96..8a0e01e498 100644 --- a/apps/ai/src/mcp/dispatcher.ts +++ b/apps/ai/src/mcp/dispatcher.ts @@ -28,6 +28,8 @@ import { RailwayMetricsService } from "@maple/backend/services/integrations/Rail import { ScrapeTargetsService } from "@maple/backend/services/integrations/ScrapeTargetsService" import { UptimeMonitorsService } from "@maple/backend/services/uptime/UptimeMonitorsService" import { IngestRuleService } from "@maple/backend/services/org/IngestRuleService" +import { StatusPagesService } from "@maple/backend/services/status-pages/StatusPagesService" +import { StatusPageSnapshotBuilder } from "@maple/backend/services/status-pages/StatusPageSnapshotBuilder" import { VcsCommitService } from "@maple/backend/services/integrations/vcs/VcsCommitService" import { IntegrationInventoryService } from "@maple/backend/services/integrations/IntegrationInventoryService" import { SourcesService } from "@maple/backend/services/org/SourcesService" @@ -274,6 +276,8 @@ const McpRuntimeServicesLive = Layer.mergeAll( RepoSandboxService.layer, ScrapeTargetsService.layer, SetupAuditService.layer, + StatusPageSnapshotBuilder.layer, + StatusPagesService.layer, UptimeMonitorsService.layer, IngestRuleService.layer, VcsCommitService.layer, diff --git a/apps/ai/src/mcp/resources/instructions.ts b/apps/ai/src/mcp/resources/instructions.ts index 97ff636bd2..417486d4d4 100644 --- a/apps/ai/src/mcp/resources/instructions.ts +++ b/apps/ai/src/mcp/resources/instructions.ts @@ -116,6 +116,7 @@ first occurrences ──> (candidate) ──> issue in \`triage\` - Deploy verification: service_deployments (versions per service, newest vs previous) -> compare_periods around the rollout. Route usage over up to 90 days: route_usage. Silence check before calling an outage: ingest_freshness. Volume per service: ingest_usage. Database query shapes by volume: db_query_volume - Alert management: list_alert_rules / list_alert_destinations -> get_alert_rule -> preview_alert_rule (dry-run before saving; a window with no data is a blind rule, not a healthy one) -> create_alert_rule / update_alert_rule / delete_alert_rule -> list_alert_incidents - Uptime: list_uptime_monitors is every http/tcp/dns check and heartbeat with its status and last check; a failed check's trace id goes to inspect_trace. Admins manage them with create_uptime_monitor / update_uptime_monitor (enabled=false pauses) / delete_uptime_monitor. A down monitor opens an alert incident with signal type \`uptime\`; its rule is managed only through the monitor. +- Status pages (what customers see, separate from alert incidents): list_status_pages -> get_status_page (component statuses, open incidents, freshness) -> create_status_page_incident / schedule_status_page_maintenance (drafts unless publish=true) -> post_status_page_update -> resolve_status_page_incident. Every message is public text. - AI agent sessions (LLM agent traces — NOT browser replays): list_agent_sessions -> get_agent_session (verdict and checks) -> get_agent_session_transcript (what it did step by step; \`failed_only\`, \`turn\`) -> inspect_span (one span in full) - Agent tool health: get_agent_tools_overview -> get_agent_tools_overview tool="" (its failure groups) -> get_agent_tool_error - Product analytics / conversion: list_product_events -> query_funnel (steps over page views, \`track()\` events and server events, stitched per person; \`breakdown_by\` a UTM/referrer dimension or an event attribute) -> add_dashboard_widget with \`panel_type: "funnel"\` and \`display_json.funnel.steps\` to pin it (\`display_json.funnel.variant: "dropoff"\` for the step-by-step drop-off view), or \`panel_type: "paths"\` with \`display_json.paths.anchor\` for what people do after / before one event diff --git a/apps/ai/src/mcp/tools/__tests__/status-page-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/status-page-tools.test.ts new file mode 100644 index 0000000000..a841ad954b --- /dev/null +++ b/apps/ai/src/mcp/tools/__tests__/status-page-tools.test.ts @@ -0,0 +1,152 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { Schema } from "effect" +import { + GetStatusPageOutput, + ListStatusPagesOutput, + StatusPageIncidentMutationOutput, +} from "@maple/domain/mcp-outputs" +import { executeSql } from "@maple/backend/platform/test-pglite" +import type { McpToolResult } from "../types" +import { makeEvalRuntime, markdown, runToolDirect, type EvalRuntime } from "../../__evals__/eval-runtime" + +let rt: EvalRuntime +beforeAll(async () => { + rt = makeEvalRuntime() + const now = Date.now() + await executeSql( + rt.testDb, + `INSERT INTO status_pages (id, org_id, name, slug, created_by, created_at, updated_at) + VALUES ($1, $2, 'Acme Status', 'acme-status', $3, to_timestamp($4 / 1000.0), to_timestamp($4 / 1000.0))`, + ["7f0b7d0e-0b6a-4f55-9a0e-2a4b9a0c1d01", rt.tenant.orgId, rt.tenant.userId, now], + ) + await executeSql( + rt.testDb, + `INSERT INTO status_page_components (id, org_id, page_id, name, position, created_at, updated_at) + VALUES ($1, $2, $3, 'API', 0, to_timestamp($4 / 1000.0), to_timestamp($4 / 1000.0))`, + [ + "7f0b7d0e-0b6a-4f55-9a0e-2a4b9a0c1d02", + rt.tenant.orgId, + "7f0b7d0e-0b6a-4f55-9a0e-2a4b9a0c1d01", + now, + ], + ) +}) +afterAll(async () => { + await rt.dispose() +}) + +const decode = >(schema: S, result: McpToolResult) => + Schema.decodeUnknownSync(schema)(result.structuredContent) + +describe("status page tools", () => { + it("lists the org's pages with their REST ids", async () => { + const output = decode(ListStatusPagesOutput, await runToolDirect(rt, "list_status_pages", {})) + expect(output.total).toBe(1) + expect(output.pages[0]).toMatchObject({ name: "Acme Status", slug: "acme-status" }) + expect(output.pages[0]?.id).toMatch(/^stp_/) + }) + + it("opens an agent incident as a draft that the page does not show", async () => { + const created = decode( + StatusPageIncidentMutationOutput, + await runToolDirect(rt, "create_status_page_incident", { + page: "acme-status", + title: "Elevated API errors", + message: "We are looking into elevated error rates.", + components: ["API"], + impact: "major", + }), + ) + expect(created.incident).toMatchObject({ + kind: "incident", + status: "investigating", + published: false, + impact: "major", + }) + expect(created.incident.id).toMatch(/^sti_/) + + // A draft names the component but does not change what visitors see. + const page = decode( + GetStatusPageOutput, + await runToolDirect(rt, "get_status_page", { page: "acme-status" }), + ) + expect(page.components).toMatchObject([{ name: "API", status: "operational" }]) + expect(page.activeIncidents.map((i) => i.id)).toContain(created.incident.id) + expect(page.freshness.publishPending).toBe(true) + + // Publishing with the next update puts it on the page. + const updated = decode( + StatusPageIncidentMutationOutput, + await runToolDirect(rt, "post_status_page_update", { + incident: created.incident.id, + status: "identified", + message: "We found the cause and are rolling back.", + publish: true, + }), + ) + expect(updated.incident).toMatchObject({ status: "identified", published: true, updateCount: 2 }) + const live = decode( + GetStatusPageOutput, + await runToolDirect(rt, "get_status_page", { page: "Acme Status" }), + ) + expect(live.components).toMatchObject([{ name: "API", status: "partial_outage" }]) + expect(live.overallStatus).toBe("partial_outage") + + const resolved = decode( + StatusPageIncidentMutationOutput, + await runToolDirect(rt, "resolve_status_page_incident", { + incident: created.incident.id, + message: "Error rates are back to normal.", + }), + ) + expect(resolved.incident.status).toBe("resolved") + expect(resolved.incident.resolvedAt).not.toBeNull() + }) + + it("publishes straight away when asked", async () => { + const created = decode( + StatusPageIncidentMutationOutput, + await runToolDirect(rt, "create_status_page_incident", { + page: "acme-status", + title: "Login delays", + message: "Some sign-ins are slow.", + publish: true, + }), + ) + expect(created.incident.published).toBe(true) + }) + + it("schedules maintenance as a draft with its window", async () => { + const result = await runToolDirect(rt, "schedule_status_page_maintenance", { + page: "acme-status", + title: "Database upgrade", + message: "The API may be read-only for a few minutes.", + starts_at: "2026-08-01 02:00:00", + ends_at: "2026-08-01T03:00:00Z", + components: ["API"], + }) + const output = decode(StatusPageIncidentMutationOutput, result) + expect(output.incident).toMatchObject({ + kind: "maintenance", + status: "scheduled", + published: false, + scheduledStart: "2026-08-01T02:00:00.000Z", + scheduledEnd: "2026-08-01T03:00:00.000Z", + }) + expect(markdown(result)).toContain("draft") + }) + + it("names the unknown component and page instead of guessing", async () => { + const component = await runToolDirect(rt, "create_status_page_incident", { + page: "acme-status", + title: "x", + message: "y", + components: ["Billing"], + }) + expect(component.isError).toBe(true) + expect(markdown(component)).toContain("API") + const page = await runToolDirect(rt, "get_status_page", { page: "nope" }) + expect(page.isError).toBe(true) + expect(markdown(page)).toContain("list_status_pages") + }) +}) diff --git a/apps/ai/src/mcp/tools/mutating.ts b/apps/ai/src/mcp/tools/mutating.ts index a459806a84..91735d8320 100644 --- a/apps/ai/src/mcp/tools/mutating.ts +++ b/apps/ai/src/mcp/tools/mutating.ts @@ -43,6 +43,11 @@ export const MUTATING_TOOL_NAMES: ReadonlySet = new Set([ "create_ingest_rule", "update_ingest_rule", "delete_ingest_rule", + // status pages: every post is public text on the customer's page + "create_status_page_incident", + "post_status_page_update", + "resolve_status_page_incident", + "schedule_status_page_maintenance", // feedback "send_maple_feedback", ]) diff --git a/apps/ai/src/mcp/tools/registry.ts b/apps/ai/src/mcp/tools/registry.ts index 96e142b9da..2e0331ada1 100644 --- a/apps/ai/src/mcp/tools/registry.ts +++ b/apps/ai/src/mcp/tools/registry.ts @@ -27,6 +27,7 @@ import { registerCompareCommitsTool } from "./compare-commits" import { registerIntegrationWriteTools } from "./integration-writes" import { registerUptimeMonitorTools } from "./uptime-monitors" import { registerIngestRuleTools } from "./ingest-rules" +import { registerStatusPageTools } from "./status-pages" import { registerGetInstrumentationRecommendationsTool } from "./get-instrumentation-recommendations" import { registerGetServiceTopOperationsTool } from "./get-service-top-operations" import { registerServiceDeploymentsTool } from "./service-deployments" @@ -435,6 +436,7 @@ const collectMapleToolDefinitions = (): ReadonlyArray => { registerIntegrationWriteTools(registrar) registerUptimeMonitorTools(registrar) registerIngestRuleTools(registrar) + registerStatusPageTools(registrar) registerSourceCodeTools(registrar) registerSandboxTools(registrar) registerPullRequestTools(registrar) diff --git a/apps/ai/src/mcp/tools/runtime-requirements.ts b/apps/ai/src/mcp/tools/runtime-requirements.ts index 5ecc9bae5d..2fda1076e3 100644 --- a/apps/ai/src/mcp/tools/runtime-requirements.ts +++ b/apps/ai/src/mcp/tools/runtime-requirements.ts @@ -19,6 +19,8 @@ import type { VcsCommitService } from "@maple/backend/services/integrations/vcs/ import type { VcsSourceService } from "@maple/backend/services/integrations/vcs/VcsSourceService" import type { UptimeMonitorsService } from "@maple/backend/services/uptime/UptimeMonitorsService" import type { IngestRuleService } from "@maple/backend/services/org/IngestRuleService" +import type { StatusPagesService } from "@maple/backend/services/status-pages/StatusPagesService" +import type { StatusPageSnapshotBuilder } from "@maple/backend/services/status-pages/StatusPageSnapshotBuilder" import type { SetupAuditService } from "@maple/backend/services/org/SetupAuditService" import type { RepoSandboxService } from "@maple/backend/services/sandbox/RepoSandboxService" import type { QueryEngineService } from "@maple/backend/services/warehouse/QueryEngineService" @@ -51,6 +53,8 @@ export type McpToolRuntimeRequirements = | RepoSandboxService | ScrapeTargetsService | SetupAuditService + | StatusPageSnapshotBuilder + | StatusPagesService | UptimeMonitorsService | IngestRuleService | VcsCommitService diff --git a/apps/ai/src/mcp/tools/status-pages.ts b/apps/ai/src/mcp/tools/status-pages.ts new file mode 100644 index 0000000000..cadb1511ea --- /dev/null +++ b/apps/ai/src/mcp/tools/status-pages.ts @@ -0,0 +1,571 @@ +/** + * Status pages: what the org's customers see about its health. Anyone may read them and run + * incidents, like the REST routes; an agent's incident starts as a draft unless it asks to publish, + * because every update is public text on the customer's page. + */ +import { Clock, Effect, Schema } from "effect" +import { + GetStatusPageOutput, + ListStatusPagesOutput, + StatusPageIncidentMutationOutput, + type StatusPageIncidentRow, + type StatusPageRow, +} from "@maple/domain/mcp-outputs" +import { decodePublicId, encodePublicId, PublicIdPrefixes } from "@maple/domain/http/v2" +import type { + StatusPageComponentRecord, + StatusPageIncidentRecord, + StatusPageRecord, +} from "@maple/domain/http" +import { + StatusPageIncidentId, + StatusPageIncidentImpact, + type OrgId, + type StatusPageComponentId, + type StatusPageIncidentStatus, +} from "@maple/domain/primitives" +import { isActiveIncident, overallStatus, pageFreshness } from "@maple/domain/status-page" +import { parseWarehouseDateTime, WarehouseTimeInput } from "@maple/query-engine" +import { StatusPagesService } from "@maple/backend/services/status-pages/StatusPagesService" +import { StatusPageSnapshotBuilder } from "@maple/backend/services/status-pages/StatusPageSnapshotBuilder" +import { McpInvalidInputError, McpQueryError, type McpToolRegistrar } from "./types" +import { CurrentMcpTenant } from "../lib/query-warehouse" +import * as P from "../lib/params" +import { doc } from "../lib/tool-doc" + +const INCIDENT_STATUSES = ["investigating", "identified", "monitoring"] as const +const MUTATION_HINTS = { readOnly: false, destructive: false, idempotent: false, openWorld: true } as const + +const iso = (ms: number) => new Date(ms).toISOString() +const isoOrNull = (ms: number | null) => (ms === null ? null : iso(ms)) + +/** Service failures as tool errors: a missing or invalid reference is the caller's to fix. */ +const toMcpError = + (tool: string) => + (error: { readonly _tag: string; readonly message: string }): McpInvalidInputError | McpQueryError => { + switch (error._tag) { + case "@maple/http/errors/StatusPageNotFoundError": + case "@maple/backend/status-pages/StatusPageGoneError": + return new McpInvalidInputError({ + message: "No such status page. list_status_pages lists them.", + parameter: "page", + }) + case "@maple/http/errors/StatusPageIncidentNotFoundError": + return new McpInvalidInputError({ + message: "No such status page incident. get_status_page lists a page's open incidents.", + parameter: "incident", + }) + case "@maple/http/errors/StatusPageComponentNotFoundError": + return new McpInvalidInputError({ + message: "No such component. get_status_page lists a page's components.", + parameter: "components", + }) + case "@maple/http/errors/StatusPageValidationError": + return new McpInvalidInputError({ message: error.message }) + default: + return new McpQueryError({ message: `${error._tag}: ${error.message}`, pipeName: tool }) + } + } + +const toPageRow = (page: StatusPageRecord): typeof StatusPageRow.Type => ({ + id: encodePublicId(PublicIdPrefixes.statusPage, page.id), + name: page.name, + slug: page.slug, + autoIncidents: page.autoIncidents, + revision: page.revision, + publishedRevision: page.publishedRevision, + publishedAt: isoOrNull(page.publishedAt), + lastPublishError: page.lastPublishError, +}) + +const toIncidentRow = (incident: StatusPageIncidentRecord): typeof StatusPageIncidentRow.Type => { + const latest = incident.updates[0] + return { + id: encodePublicId(PublicIdPrefixes.statusPageIncident, incident.id), + pageId: encodePublicId(PublicIdPrefixes.statusPage, incident.pageId), + kind: incident.kind, + title: incident.title, + impact: incident.impact, + status: incident.status, + published: incident.published, + startedAt: iso(incident.startedAt), + resolvedAt: isoOrNull(incident.resolvedAt), + scheduledStart: isoOrNull(incident.scheduledStart), + scheduledEnd: isoOrNull(incident.scheduledEnd), + componentIds: incident.componentIds.map((id) => + encodePublicId(PublicIdPrefixes.statusPageComponent, id), + ), + latestUpdate: + latest === undefined + ? null + : { status: latest.status, body: latest.body, at: iso(latest.createdAt) }, + updateCount: incident.updates.length, + } +} + +/** A page by its id, REST id (`stp_…`), slug or exact name. */ +const resolvePage = Effect.fnUntraced(function* (tool: string, orgId: OrgId, ref: string) { + const service = yield* StatusPagesService + const pages = yield* service.list(orgId).pipe(Effect.mapError(toMcpError(tool))) + const value = ref.trim() + const id = decodePublicId(PublicIdPrefixes.statusPage, value) ?? value + const matches = pages.filter((page) => page.id === id || page.slug === value || page.name === value) + const [page] = matches + if (page === undefined) { + return yield* new McpInvalidInputError({ + message: `No status page "${ref}". list_status_pages lists them.`, + parameter: "page", + }) + } + if (matches.length > 1) { + return yield* new McpInvalidInputError({ + message: `${matches.length} status pages match "${ref}"; pass one's slug or id instead.`, + parameter: "page", + }) + } + return page +}) + +const decodeIncidentId = (ref: string) => { + const value = ref.trim() + return Schema.decodeUnknownEffect(StatusPageIncidentId)( + decodePublicId(PublicIdPrefixes.statusPageIncident, value) ?? value, + ).pipe( + Effect.mapError( + () => + new McpInvalidInputError({ + message: `"${ref}" is not a status page incident id (sti_… or its UUID). get_status_page lists open incidents.`, + parameter: "incident", + }), + ), + ) +} + +/** Component references (id, `stc_…` or exact name) resolved against the page's components. */ +const resolveComponents = ( + components: ReadonlyArray, + refs: ReadonlyArray, +): Effect.Effect, McpInvalidInputError> => + Effect.forEach(refs, (ref) => { + const value = ref.trim() + const id = decodePublicId(PublicIdPrefixes.statusPageComponent, value) ?? value + const found = components.find((component) => component.id === id || component.name === value) + return found === undefined + ? Effect.fail( + new McpInvalidInputError({ + message: `No component "${ref}" on this page. Its components: ${components.map((c) => c.name).join(", ") || "none"}.`, + parameter: "components", + }), + ) + : Effect.succeed(found.id) + }) + +const pageComponents = Effect.fnUntraced(function* ( + tool: string, + orgId: OrgId, + pageId: StatusPageRecord["id"], +) { + const service = yield* StatusPagesService + return yield* service.listComponents(orgId, pageId).pipe(Effect.mapError(toMcpError(tool))) +}) + +const renderIncident = (title: string, output: typeof StatusPageIncidentMutationOutput.Type) => { + const incident = output.incident + return { + title, + notices: incident.published + ? [] + : [ + "This is a draft: it is not on the public page. post_status_page_update with publish=true publishes it.", + ], + blocks: [ + doc.fields([ + ["Title", incident.title], + ["Id", incident.id], + ["Kind", incident.kind], + ["Status", incident.status], + ["Impact", incident.impact], + ["Published", incident.published ? "yes" : "no (draft)"], + [ + "Window", + incident.scheduledStart === null + ? undefined + : `${incident.scheduledStart} to ${incident.scheduledEnd ?? "?"}`, + ], + ["Latest update", incident.latestUpdate?.body], + ]), + ], + next: [doc.next("get_status_page", { page: incident.pageId }, "the page with this incident")], + } +} + +/** A required UTC time; annotated on the wire side, where the published schema reads it. */ +const utcTime = (meaning: string) => + Schema.String.annotate({ description: `${meaning}, UTC (YYYY-MM-DD HH:mm:ss or ISO 8601)` }).pipe( + Schema.decodeTo(WarehouseTimeInput), + ) + +const publishParameter = P.optionalFlag( + "true puts it on the public page now. Default false: a draft a person reviews and publishes", +) + +export function registerStatusPageTools(server: McpToolRegistrar) { + server.define({ + name: "list_status_pages", + title: "List Status Pages", + description: + "Every public status page of the org with its slug and publish state (revision vs published revision, last publish error). get_status_page shows one page's components, open incidents and freshness.", + parameters: Schema.Struct({}), + output: ListStatusPagesOutput, + hints: { readOnly: true, destructive: false, idempotent: true }, + phrases: ["Checking status pages"], + handler: Effect.fn("McpTool.listStatusPages")(function* () { + const tenant = yield* CurrentMcpTenant + const service = yield* StatusPagesService + const pages = yield* service + .list(tenant.orgId) + .pipe(Effect.mapError(toMcpError("list_status_pages"))) + yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId, "result.rowCount": pages.length }) + return { pages: pages.map(toPageRow), total: pages.length } + }), + render: (output) => ({ + title: "Status pages", + ...(output.pages.length === 0 + ? { + empty: { + message: "This organization has no status pages.", + hints: ["Create one in Maple under Status pages, or with POST /v2/status_pages."], + }, + } + : undefined), + blocks: + output.pages.length === 0 + ? [] + : [ + doc.table( + ["Name", "Slug", "Id", "Published", "Publish error"], + output.pages.map((page) => [ + page.name, + page.slug, + page.id, + page.publishedAt === null + ? "not yet" + : page.publishedRevision < page.revision + ? `${page.publishedAt} (change pending)` + : page.publishedAt, + page.lastPublishError ?? "-", + ]), + ), + ], + next: output.pages + .slice(0, 3) + .map((page) => + doc.next( + "get_status_page", + { page: page.slug }, + `${page.name}'s components and incidents`, + ), + ), + }), + }) + + server.define({ + name: "get_status_page", + title: "Get Status Page", + description: + "One status page as its visitors see it: each component's status (worst of its uptime monitor, manual override and published incidents), the overall banner, open incidents and maintenance (drafts included, marked), and whether its live checks are fresh or delayed.", + parameters: Schema.Struct({ + page: P.text("The page's slug, name or id (list_status_pages lists them)"), + }), + output: GetStatusPageOutput, + hints: { readOnly: true, destructive: false, idempotent: true }, + phrases: ["Reading a status page"], + handler: Effect.fn("McpTool.getStatusPage")(function* (params) { + const tenant = yield* CurrentMcpTenant + const service = yield* StatusPagesService + const builder = yield* StatusPageSnapshotBuilder + const page = yield* resolvePage("get_status_page", tenant.orgId, params.page) + const nowMs = yield* Clock.currentTimeMillis + // The builder reads by page id; `resolvePage` already scoped it to this org. + const snapshot = yield* builder + .build(page.id, nowMs) + .pipe(Effect.mapError(toMcpError("get_status_page"))) + const records = yield* pageComponents("get_status_page", tenant.orgId, page.id) + const incidents = yield* service + .listIncidents(tenant.orgId, page.id) + .pipe(Effect.mapError(toMcpError("get_status_page"))) + // Answering means Maple is up, so this is the probe side of freshness only. + const freshness = pageFreshness({ snapshot, heartbeatAt: nowMs, nowMs }) + return { + page: toPageRow(page), + overallStatus: overallStatus(snapshot), + components: snapshot.components.map((component) => { + const record = records.find((candidate) => candidate.id === component.id) + return { + id: encodePublicId(PublicIdPrefixes.statusPageComponent, component.id), + name: component.name, + group: component.group, + status: component.status, + monitorId: + record?.monitorId == null + ? null + : encodePublicId(PublicIdPrefixes.uptimeMonitor, record.monitorId), + manualStatus: record?.manualStatus ?? null, + } + }), + activeIncidents: incidents.map(toIncidentRow), + freshness: { + state: freshness._tag, + updatedAt: iso(freshness.updatedAt), + publishPending: page.publishedRevision < page.revision, + }, + } + }), + render: (output) => ({ + title: `Status page: ${output.page.name}`, + notices: [ + ...(output.freshness.state === "delayed" + ? [ + `Live checks are delayed: the page's monitors last reported at ${output.freshness.updatedAt}.`, + ] + : []), + ...(output.page.lastPublishError === null + ? [] + : [ + `The last publish failed: ${output.page.lastPublishError}. The next minutely publish retries.`, + ]), + ], + blocks: [ + doc.fields([ + ["Slug", output.page.slug], + ["Id", output.page.id], + ["Overall", output.overallStatus], + ["Published", output.page.publishedAt ?? "not yet"], + [ + "Change pending", + output.freshness.publishPending ? "yes, publishes within a minute" : "no", + ], + ]), + ...(output.components.length === 0 + ? [doc.text("No components yet.")] + : [ + doc.table( + ["Component", "Group", "Status", "Monitor", "Id"], + output.components.map((c) => [ + c.name, + c.group ?? "-", + c.status, + c.monitorId ?? "-", + c.id, + ]), + ), + ]), + ...(output.activeIncidents.length === 0 + ? [doc.text("No open incidents or maintenance.")] + : [ + doc.table( + ["Title", "Kind", "Status", "Impact", "Published", "Id"], + output.activeIncidents.map((i) => [ + i.title, + i.kind, + i.status, + i.impact, + i.published ? "yes" : "draft", + i.id, + ]), + ), + ]), + ], + next: output.activeIncidents + .filter((incident) => isActiveIncident(incident)) + .slice(0, 2) + .map((incident) => + doc.next( + "post_status_page_update", + { incident: incident.id }, + `post the next update on "${incident.title}"`, + ), + ), + }), + }) + + server.define({ + name: "create_status_page_incident", + title: "Create Status Page Incident", + description: + "Open a customer-facing incident on a status page with its first public update (`message`). `components` names the affected components; `impact` sets how hard it hits them. Starts as an unpublished draft unless publish=true: everything here is public text your customers read. Separate from internal alert incidents.", + parameters: Schema.Struct({ + page: P.text("The page's slug, name or id"), + title: P.text("Public title, e.g. `Elevated API errors`"), + message: P.text("The first public update, written for customers"), + impact: P.optionalOneOf( + StatusPageIncidentImpact.literals, + "How hard it hits the components (default minor)", + ), + status: P.optionalOneOf(INCIDENT_STATUSES, "Where the response stands (default investigating)"), + components: P.optionalList("Affected components, by name or id (get_status_page lists them)"), + publish: publishParameter, + }), + output: StatusPageIncidentMutationOutput, + hints: MUTATION_HINTS, + phrases: ["Opening a status page incident"], + handler: Effect.fn("McpTool.createStatusPageIncident")(function* (params) { + const tool = "create_status_page_incident" + const tenant = yield* CurrentMcpTenant + const service = yield* StatusPagesService + const page = yield* resolvePage(tool, tenant.orgId, params.page) + const components = yield* pageComponents(tool, tenant.orgId, page.id) + const componentIds = yield* resolveComponents(components, params.components ?? []) + const created = yield* service + .createIncident(tenant.orgId, page.id, { + kind: "incident", + title: params.title, + impact: params.impact ?? "minor", + status: params.status ?? "investigating", + body: params.message, + componentIds, + published: params.publish === true, + source: "mcp", + createdBy: tenant.userId, + }) + .pipe(Effect.mapError(toMcpError(tool))) + return { action: "created" as const, incident: toIncidentRow(created) } + }), + render: (output) => renderIncident("Status page incident opened", output), + }) + + server.define({ + name: "post_status_page_update", + title: "Post Status Page Update", + description: + "Post the next public update on a status page incident or maintenance and move it to `status`. Pass publish=true to also publish a draft. To close an incident use resolve_status_page_incident. The message is public text your customers read.", + parameters: Schema.Struct({ + incident: P.text("The incident's id (sti_… or its UUID; get_status_page lists open ones)"), + message: P.text("The public update, written for customers"), + status: P.oneOf( + ["investigating", "identified", "monitoring", "scheduled", "in_progress"] as const, + "Incidents: investigating, identified, monitoring. Maintenance: scheduled, in_progress", + ), + impact: P.optionalOneOf(StatusPageIncidentImpact.literals, "A new impact; omit to keep it"), + components: P.optionalList("Replaces the affected components, by name or id"), + publish: P.optionalFlag("true publishes a draft along with this update"), + }), + output: StatusPageIncidentMutationOutput, + hints: MUTATION_HINTS, + phrases: ["Posting a status page update"], + handler: Effect.fn("McpTool.postStatusPageUpdate")(function* (params) { + const tool = "post_status_page_update" + const tenant = yield* CurrentMcpTenant + const service = yield* StatusPagesService + const incidentId = yield* decodeIncidentId(params.incident) + const current = yield* service + .getIncident(tenant.orgId, incidentId) + .pipe(Effect.mapError(toMcpError(tool))) + const componentIds = + params.components === undefined + ? undefined + : yield* resolveComponents( + yield* pageComponents(tool, tenant.orgId, current.pageId), + params.components, + ) + const posted = yield* service + .postUpdate(tenant.orgId, incidentId, { + status: params.status, + body: params.message, + ...(params.impact === undefined ? undefined : { impact: params.impact }), + ...(componentIds === undefined ? undefined : { componentIds }), + source: "mcp", + createdBy: tenant.userId, + }) + .pipe(Effect.mapError(toMcpError(tool))) + // Published only once the update itself was accepted. + const updated = + params.publish === true && !posted.published + ? yield* service + .updateIncident(tenant.orgId, incidentId, { published: true }) + .pipe(Effect.mapError(toMcpError(tool))) + : posted + return { action: "updated" as const, incident: toIncidentRow(updated) } + }), + render: (output) => renderIncident("Status page update posted", output), + }) + + server.define({ + name: "resolve_status_page_incident", + title: "Resolve Status Page Incident", + description: + "Close a status page incident (or complete a maintenance) with a final public update. The message is public text your customers read.", + parameters: Schema.Struct({ + incident: P.text("The incident's id (sti_… or its UUID)"), + message: P.text("The closing public update, e.g. what was fixed"), + }), + output: StatusPageIncidentMutationOutput, + hints: MUTATION_HINTS, + phrases: ["Resolving a status page incident"], + handler: Effect.fn("McpTool.resolveStatusPageIncident")(function* (params) { + const tool = "resolve_status_page_incident" + const tenant = yield* CurrentMcpTenant + const service = yield* StatusPagesService + const incidentId = yield* decodeIncidentId(params.incident) + const current = yield* service + .getIncident(tenant.orgId, incidentId) + .pipe(Effect.mapError(toMcpError(tool))) + const status: StatusPageIncidentStatus = current.kind === "maintenance" ? "completed" : "resolved" + const updated = yield* service + .postUpdate(tenant.orgId, incidentId, { + status, + body: params.message, + source: "mcp", + createdBy: tenant.userId, + }) + .pipe(Effect.mapError(toMcpError(tool))) + return { action: "resolved" as const, incident: toIncidentRow(updated) } + }), + render: (output) => renderIncident("Status page incident resolved", output), + }) + + server.define({ + name: "schedule_status_page_maintenance", + title: "Schedule Status Page Maintenance", + description: + "Announce planned maintenance on a status page for a window (`starts_at` to `ends_at`, UTC). The named components show `maintenance` while it is in progress. Starts as an unpublished draft unless publish=true: the message is public text your customers read.", + parameters: Schema.Struct({ + page: P.text("The page's slug, name or id"), + title: P.text("Public title, e.g. `Database upgrade`"), + message: P.text("What will happen and what customers may notice"), + starts_at: utcTime("When it starts"), + ends_at: utcTime("When it ends"), + impact: P.optionalOneOf(StatusPageIncidentImpact.literals, "Expected impact (default none)"), + components: P.optionalList("Components under maintenance, by name or id"), + publish: publishParameter, + }), + output: StatusPageIncidentMutationOutput, + hints: MUTATION_HINTS, + phrases: ["Scheduling maintenance"], + handler: Effect.fn("McpTool.scheduleStatusPageMaintenance")(function* (params) { + const tool = "schedule_status_page_maintenance" + const tenant = yield* CurrentMcpTenant + const service = yield* StatusPagesService + const page = yield* resolvePage(tool, tenant.orgId, params.page) + const components = yield* pageComponents(tool, tenant.orgId, page.id) + const componentIds = yield* resolveComponents(components, params.components ?? []) + const created = yield* service + .createIncident(tenant.orgId, page.id, { + kind: "maintenance", + title: params.title, + impact: params.impact ?? "none", + status: "scheduled", + body: params.message, + componentIds, + published: params.publish === true, + scheduledStart: parseWarehouseDateTime(params.starts_at), + scheduledEnd: parseWarehouseDateTime(params.ends_at), + source: "mcp", + createdBy: tenant.userId, + }) + .pipe(Effect.mapError(toMcpError(tool))) + return { action: "scheduled" as const, incident: toIncidentRow(created) } + }), + render: (output) => renderIncident("Maintenance scheduled", output), + }) +} diff --git a/apps/ai/test/mcp/unimplemented-tool-services.ts b/apps/ai/test/mcp/unimplemented-tool-services.ts index 1be6010867..d81f2166af 100644 --- a/apps/ai/test/mcp/unimplemented-tool-services.ts +++ b/apps/ai/test/mcp/unimplemented-tool-services.ts @@ -23,6 +23,8 @@ import { VcsSourceService } from "@maple/backend/services/integrations/vcs/VcsSo import { SetupAuditService } from "@maple/backend/services/org/SetupAuditService" import { UptimeMonitorsService } from "@maple/backend/services/uptime/UptimeMonitorsService" import { IngestRuleService } from "@maple/backend/services/org/IngestRuleService" +import { StatusPagesService } from "@maple/backend/services/status-pages/StatusPagesService" +import { StatusPageSnapshotBuilder } from "@maple/backend/services/status-pages/StatusPageSnapshotBuilder" import { RepoSandboxService } from "@maple/backend/services/sandbox/RepoSandboxService" import { QueryEngineService } from "@maple/backend/services/warehouse/QueryEngineService" import { WarehouseQueryService } from "@maple/backend/services/warehouse/WarehouseQueryService" @@ -50,6 +52,8 @@ export const unimplementedMcpToolServices = Layer.mergeAll( Layer.mock(SetupAuditService)({}), Layer.mock(UptimeMonitorsService)({}), Layer.mock(IngestRuleService)({}), + Layer.mock(StatusPagesService)({}), + Layer.mock(StatusPageSnapshotBuilder)({}), Layer.mock(RepoSandboxService)({}), Layer.mock(QueryEngineService)({}), WarehouseQueryService.layer, diff --git a/packages/domain/src/mcp-outputs/catalog.ts b/packages/domain/src/mcp-outputs/catalog.ts index 878aa53355..e790b2e815 100644 --- a/packages/domain/src/mcp-outputs/catalog.ts +++ b/packages/domain/src/mcp-outputs/catalog.ts @@ -6,6 +6,7 @@ import { SendMapleFeedbackOutput } from "./feedback" import { InspectInfraOutput, ListInfraOutput } from "./infra" import { ListSourcesOutput } from "./sources" import { DeleteUptimeMonitorOutput, ListUptimeMonitorsOutput, UptimeMonitorMutationOutput } from "./uptime" +import { GetStatusPageOutput, ListStatusPagesOutput, StatusPageIncidentMutationOutput } from "./status-pages" import { DeleteIngestRuleOutput, IngestRuleMutationOutput, @@ -127,6 +128,7 @@ export const McpToolOutputs = { create_ingest_rule: IngestRuleMutationOutput, create_scrape_target: ScrapeTargetMutationOutput, create_dashboard: CreateDashboardOutput, + create_status_page_incident: StatusPageIncidentMutationOutput, create_uptime_monitor: UptimeMonitorMutationOutput, db_query_volume: DbQueryVolumeOutput, delete_ingest_rule: DeleteIngestRuleOutput, @@ -172,7 +174,12 @@ export const McpToolOutputs = { list_infra: ListInfraOutput, list_integrations: ListIntegrationsOutput, list_sources: ListSourcesOutput, + list_status_pages: ListStatusPagesOutput, list_uptime_monitors: ListUptimeMonitorsOutput, + get_status_page: GetStatusPageOutput, + post_status_page_update: StatusPageIncidentMutationOutput, + resolve_status_page_incident: StatusPageIncidentMutationOutput, + schedule_status_page_maintenance: StatusPageIncidentMutationOutput, list_metrics: ListMetricsOutput, list_product_events: ListProductEventsOutput, list_services: ListServicesOutput, diff --git a/packages/domain/src/mcp-outputs/index.ts b/packages/domain/src/mcp-outputs/index.ts index 0949283b20..424ec914dd 100644 --- a/packages/domain/src/mcp-outputs/index.ts +++ b/packages/domain/src/mcp-outputs/index.ts @@ -15,4 +15,5 @@ export * from "./integrations" export * from "./sources" export * from "./ci" export * from "./uptime" +export * from "./status-pages" export * from "./catalog" diff --git a/packages/domain/src/mcp-outputs/status-pages.ts b/packages/domain/src/mcp-outputs/status-pages.ts new file mode 100644 index 0000000000..1626e330cf --- /dev/null +++ b/packages/domain/src/mcp-outputs/status-pages.ts @@ -0,0 +1,92 @@ +import { Schema } from "effect" +import { + StatusPageAutoIncidents, + StatusPageComponentStatus, + StatusPageIncidentImpact, + StatusPageIncidentKind, + StatusPageIncidentStatus, +} from "../primitives" + +// --------------------------------------------------------------------------------------------- +// list_status_pages / get_status_page / create_status_page_incident / post_status_page_update / +// resolve_status_page_incident / schedule_status_page_maintenance + +export const StatusPageRow = Schema.Struct({ + /** REST id (`stp_…`). */ + id: Schema.String, + name: Schema.String, + slug: Schema.String, + autoIncidents: StatusPageAutoIncidents, + revision: Schema.Number, + publishedRevision: Schema.Number, + /** ISO timestamp of the last publish; null before the first one. */ + publishedAt: Schema.NullOr(Schema.String), + lastPublishError: Schema.NullOr(Schema.String), +}) + +export const ListStatusPagesOutput = Schema.Struct({ + pages: Schema.Array(StatusPageRow), + total: Schema.Number, +}) + +export const StatusPageComponentRow = Schema.Struct({ + /** REST id (`stc_…`). */ + id: Schema.String, + name: Schema.String, + group: Schema.NullOr(Schema.String), + /** What visitors see: the worst of the monitor, the manual override and published incidents. */ + status: StatusPageComponentStatus, + /** REST id of the linked uptime monitor (`upm_…`), or null. */ + monitorId: Schema.NullOr(Schema.String), + manualStatus: Schema.NullOr(StatusPageComponentStatus), +}) + +export const StatusPageIncidentUpdateRow = Schema.Struct({ + status: StatusPageIncidentStatus, + body: Schema.String, + at: Schema.String, +}) + +export const StatusPageIncidentRow = Schema.Struct({ + /** REST id (`sti_…`). */ + id: Schema.String, + /** REST id of the page (`stp_…`). */ + pageId: Schema.String, + kind: StatusPageIncidentKind, + title: Schema.String, + impact: StatusPageIncidentImpact, + status: StatusPageIncidentStatus, + /** Drafts are not on the page until published. */ + published: Schema.Boolean, + startedAt: Schema.String, + resolvedAt: Schema.NullOr(Schema.String), + scheduledStart: Schema.NullOr(Schema.String), + scheduledEnd: Schema.NullOr(Schema.String), + componentIds: Schema.Array(Schema.String), + latestUpdate: Schema.NullOr(StatusPageIncidentUpdateRow), + updateCount: Schema.Number, +}) + +export const StatusPageFreshnessRow = Schema.Struct({ + /** `delayed` when the page's monitors have not reported in the last minutes. */ + state: Schema.Literals(["live", "delayed"]), + /** When the page's state was last confirmed by a check result. */ + updatedAt: Schema.String, + /** Whether a change is still waiting for the next publish. */ + publishPending: Schema.Boolean, +}) + +export const GetStatusPageOutput = Schema.Struct({ + page: StatusPageRow, + /** The banner: the worst component status. */ + overallStatus: StatusPageComponentStatus, + components: Schema.Array(StatusPageComponentRow), + /** Open incidents and maintenance not yet completed, drafts included; newest first. */ + activeIncidents: Schema.Array(StatusPageIncidentRow), + freshness: StatusPageFreshnessRow, +}) + +export const StatusPageIncidentMutationOutput = Schema.Struct({ + action: Schema.Literals(["created", "updated", "resolved", "scheduled"]), + incident: StatusPageIncidentRow, +}) From a62335ebe57d166e64ecbd376c7d6cb8bf9302cd Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 28 Oct 2026 16:31:46 +0100 Subject: [PATCH 03/10] fix(status-pages): share the probe pulse query between the heartbeat and get_status_page --- apps/ai/src/mcp/tools/status-pages.ts | 5 +++- .../status-pages/StatusPagePublisher.ts | 23 +++------------- .../status-pages/StatusPageSnapshotBuilder.ts | 26 ++++++++++++++++++- 3 files changed, 32 insertions(+), 22 deletions(-) diff --git a/apps/ai/src/mcp/tools/status-pages.ts b/apps/ai/src/mcp/tools/status-pages.ts index cadb1511ea..c50100f6e5 100644 --- a/apps/ai/src/mcp/tools/status-pages.ts +++ b/apps/ai/src/mcp/tools/status-pages.ts @@ -295,7 +295,10 @@ export function registerStatusPageTools(server: McpToolRegistrar) { .listIncidents(tenant.orgId, page.id) .pipe(Effect.mapError(toMcpError("get_status_page"))) // Answering means Maple is up, so this is the probe side of freshness only. - const freshness = pageFreshness({ snapshot, heartbeatAt: nowMs, nowMs }) + const probesSeenAt = yield* builder + .probesSeenAt(nowMs) + .pipe(Effect.mapError(toMcpError("get_status_page"))) + const freshness = pageFreshness({ snapshot, heartbeat: { at: nowMs, probesSeenAt }, nowMs }) return { page: toPageRow(page), overallStatus: overallStatus(snapshot), diff --git a/packages/backend/src/services/status-pages/StatusPagePublisher.ts b/packages/backend/src/services/status-pages/StatusPagePublisher.ts index d26768645c..3de3a346a5 100644 --- a/packages/backend/src/services/status-pages/StatusPagePublisher.ts +++ b/packages/backend/src/services/status-pages/StatusPagePublisher.ts @@ -1,8 +1,7 @@ import * as PG from "@maple-dev/effect-orm/postgres" -import { StatusPageCleanups, StatusPages, UptimeMonitorDaily, UptimeMonitors } from "@maple/db/tables" +import { StatusPageCleanups, StatusPages } from "@maple/db/tables" import type { StatusPageCleanupRow } from "@maple/db/tables" import type { StatusPageId } from "@maple/domain/primitives" -import { utcDay } from "@maple/domain/status-page" import { Clock, Context, Effect, Layer } from "effect" import { Database } from "@maple/backend/platform/DatabaseLive" import { makeDbExecute, makePersistenceErrorMapper } from "@maple/backend/platform/db-execute" @@ -18,7 +17,6 @@ export const STATUS_PAGE_SWEEP_BATCH = 100 /** Edge cleanups one sweep drains at most, oldest first. */ export const STATUS_PAGE_CLEANUP_BATCH = 50 const SWEEP_CONCURRENCY = 4 -const DAY_MS = 86_400_000 export interface StatusPagePublishResult { readonly pageId: StatusPageId @@ -241,23 +239,8 @@ export class StatusPagePublisher extends Context.Service - db.run( - PG.from(UptimeMonitorDaily, "d") - .innerJoin(UptimeMonitors, "m", (d, m) => - d.monitorId.eq(m.id).and(d.orgId.eq(m.orgId)), - ) - .select(($) => ({ at: PG.max($.lastCheckAt) })) - .where(($) => [ - $.m.enabled.eq(true), - $.m.kind.neq("heartbeat"), - PG.inList($.day, [utcDay(nowMs), utcDay(nowMs - DAY_MS)]), - ]), - ), - ) - yield* store.putHeartbeat({ at: nowMs, probesSeenAt: seen?.at ?? null }) + const probesSeenAt = yield* builder.probesSeenAt(nowMs) + yield* store.putHeartbeat({ at: nowMs, probesSeenAt }) }) const sweep = Effect.fn("StatusPagePublisher.sweep")(function* (nowMs: number) { diff --git a/packages/backend/src/services/status-pages/StatusPageSnapshotBuilder.ts b/packages/backend/src/services/status-pages/StatusPageSnapshotBuilder.ts index 81b977a5d8..6194d98af0 100644 --- a/packages/backend/src/services/status-pages/StatusPageSnapshotBuilder.ts +++ b/packages/backend/src/services/status-pages/StatusPageSnapshotBuilder.ts @@ -6,6 +6,7 @@ import { StatusPageIncidentUpdates, StatusPages, UptimeMonitorDaily, + UptimeMonitors, UptimeMonitorStates, } from "@maple/db/tables" import type { @@ -31,6 +32,7 @@ import { STATUS_PAGE_SNAPSHOT_VERSION, type StatusPageSnapshot, type StatusPageSnapshotIncident, + utcDay, } from "@maple/domain/status-page" import { Context, Effect, Layer } from "effect" import { Database } from "@maple/backend/platform/DatabaseLive" @@ -225,6 +227,8 @@ export interface StatusPageSnapshotBuilderApi { pageId: StatusPageId, nowMs: number, ) => Effect.Effect + /** Newest scheduled check across all monitors (the prober's pulse); null when none ran lately. */ + readonly probesSeenAt: (nowMs: number) => Effect.Effect } const toDbError = makePersistenceErrorMapper(StatusPagePublishDbError, "Status page snapshot read failed") @@ -372,7 +376,27 @@ export class StatusPageSnapshotBuilder extends Context.Service< return toSnapshot({ ...input, nowMs }) }) - return { build } + const probesSeenAt = Effect.fn("StatusPageSnapshotBuilder.probesSeenAt")(function* (nowMs: number) { + // The daily counters fold in every scheduled check; their PK (monitor, day) serves this + // per enabled monitor. Heartbeat monitors are excluded: their misses are not probes. + const [seen] = yield* dbExecute((db) => + db.run( + PG.from(UptimeMonitorDaily, "d") + .innerJoin(UptimeMonitors, "m", (d, m) => + d.monitorId.eq(m.id).and(d.orgId.eq(m.orgId)), + ) + .select(($) => ({ at: PG.max($.lastCheckAt) })) + .where(($) => [ + $.m.enabled.eq(true), + $.m.kind.neq("heartbeat"), + PG.inList($.day, [utcDay(nowMs), utcDay(nowMs - DAY_MS)]), + ]), + ), + ) + return seen?.at ?? null + }) + + return { build, probesSeenAt } }), }) { static readonly layer = Layer.effect(this, this.make) From 6be0f0285f23ef2b7737ce4f80b87820519e7125 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 28 Oct 2026 16:32:45 +0100 Subject: [PATCH 04/10] feat(status-pages): bind snapshot KV and R2 to the api Worker for publish and delete --- apps/api/src/runtime/service-graph.ts | 3 +++ apps/api/src/worker/bindings.ts | 12 ++++++++++++ apps/api/test/offline-http-ports.ts | 2 ++ 3 files changed, 17 insertions(+) diff --git a/apps/api/src/runtime/service-graph.ts b/apps/api/src/runtime/service-graph.ts index 446eef9dfc..e4a0ed7cc7 100644 --- a/apps/api/src/runtime/service-graph.ts +++ b/apps/api/src/runtime/service-graph.ts @@ -57,6 +57,7 @@ import { OrgOpenRouterKeyService } from "@maple/backend/services/org/OrgOpenRout import { ScrapeTargetsService } from "@maple/backend/services/integrations/ScrapeTargetsService" import { UptimeMonitorsService } from "@maple/backend/services/uptime/UptimeMonitorsService" import { StatusPagesService } from "@maple/backend/services/status-pages/StatusPagesService" +import { StatusPagePublisher } from "@maple/backend/services/status-pages/StatusPagePublisher" import { ChatWorkspaceService } from "@maple/backend/services/integrations/ChatWorkspaceService" import { TinybirdOrgTokenService } from "@maple/backend/services/integrations/TinybirdOrgTokenService" import { PlanetScaleWebhookQueue } from "@maple/backend/services/integrations/planetscale/PlanetScaleWebhookQueue" @@ -131,6 +132,8 @@ export const HttpServicesLive = Layer.mergeAll( ScrapeTargetsService.layer, UptimeMonitorsService.layer, StatusPagesService.layer, + // Needs the `StatusPageStore` port, which only the deployed Worker binds. + StatusPagePublisher.layer, PlanetScaleConnectionService.layer, PlanetScaleService.layer, PlanetScaleWebhookService.layer, diff --git a/apps/api/src/worker/bindings.ts b/apps/api/src/worker/bindings.ts index d48ef3b67e..8c79f3b57c 100644 --- a/apps/api/src/worker/bindings.ts +++ b/apps/api/src/worker/bindings.ts @@ -6,6 +6,8 @@ * depends on (`apiPorts`). * */ +import { StatusPageStore } from "@maple/backend/services/status-pages/StatusPageStore" +import { StatusSnapshotsBucket, StatusSnapshotsKv } from "@maple/infra/cloudflare" import { MapleDb } from "@maple/infra/cloudflare" import * as Cloudflare from "@alchemy.run/cloudflare" import { RuntimeContext } from "alchemy/RuntimeContext" @@ -53,6 +55,9 @@ export const bindApiClients = Effect.gen(function* () { auditEvents: yield* Cloudflare.Queues.WriteQueue(AuditEventsQueue), // Read side of the replay payload store. replayBlobs: yield* Cloudflare.R2.ReadBucket(ReplayBlobs), + // Published status pages: the publish endpoint writes snapshots, page deletes clear them. + statusKv: yield* Cloudflare.KV.ReadWriteNamespace(StatusSnapshotsKv), + statusBucket: yield* Cloudflare.R2.ReadWriteBucket(StatusSnapshotsBucket), apiV2RateLimit: yield* Cloudflare.RateLimit("API_V2_RATE_LIMITER", { namespaceId: 2026071801, simple: { limit: API_V2_RATE_LIMIT_REQUESTS, period: API_V2_RATE_LIMIT_PERIOD_SECONDS }, @@ -79,6 +84,8 @@ export const ApiBindingsLive = Layer.mergeAll( Cloudflare.Hyperdrive.ConnectBinding, Cloudflare.Queues.WriteQueueBinding, Cloudflare.R2.ReadBucketBinding, + Cloudflare.KV.ReadWriteNamespaceBinding, + Cloudflare.R2.ReadWriteBucketBinding, Cloudflare.Workers.RateLimitBinding, Cloudflare.Email.SendBinding, ) @@ -173,6 +180,11 @@ export const apiPorts = ( Layer.succeed(CliAuthRateLimit, limiter(clients.cliAuthRateLimit)), Layer.succeed(McpOAuthRateLimit, limiter(clients.mcpOAuthRateLimit)), Layer.succeed(ReplayBlobBucket, objectStore(clients.replayBlobs)), + Layer.unwrap( + runtime(Effect.all([clients.statusKv.raw, clients.statusBucket.raw])).pipe( + Effect.map(([kv, bucket]) => StatusPageStore.fromBindings(kv, bucket)), + ), + ), Layer.succeed(EmailSender, clients.email), Layer.succeed(SchemaApplyWorkflow, workflowStarter(schemaApply)), layerMapleDbConnection(env), diff --git a/apps/api/test/offline-http-ports.ts b/apps/api/test/offline-http-ports.ts index ea9fdc77f0..01e9eb0fef 100644 --- a/apps/api/test/offline-http-ports.ts +++ b/apps/api/test/offline-http-ports.ts @@ -1,4 +1,5 @@ /** Offline graph fixtures. Every external operation fails rather than reaching a real service. */ +import { StatusPageStore } from "@maple/backend/services/status-pages/StatusPageStore" import { Effect, Layer, Option } from "effect" import { fakeChatSessionsLayer } from "@maple/backend/platform/chat-sessions-fake" import { envPorts } from "@maple/backend/platform/env-ports" @@ -25,6 +26,7 @@ export const offlinePorts = Layer.mergeAll( Layer.succeed(CliAuthRateLimit, limiter), Layer.succeed(McpOAuthRateLimit, limiter), Layer.succeed(ReplayBlobBucket, { getBytes: rejectIO }), + StatusPageStore.inMemory, Layer.succeed(MapleDbConnection, Option.none()), Layer.succeed(EmailSender, Option.none()), Layer.succeed(SchemaApplyWorkflow, { create: rejectIO }), From 9b0d9c636f568a718706cf0c95ef9fd52ac609f3 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 28 Oct 2026 16:50:46 +0100 Subject: [PATCH 05/10] fix(status-pages): org-scoped probe pulse for get_status_page, delayed with no probes, incidents paged in SQL, guarded slug delete --- .../tools/__tests__/status-page-tools.test.ts | 43 +++++++++++++++++++ apps/ai/src/mcp/tools/status-pages.ts | 17 +++++--- apps/api/src/routes/v2/status-pages.http.ts | 39 ++++++++--------- .../StatusPageSnapshotBuilder.test.ts | 31 ++++++++++++- .../status-pages/StatusPageSnapshotBuilder.ts | 17 ++++++-- .../status-pages/StatusPagesService.test.ts | 41 ++++++++++++++++++ .../status-pages/StatusPagesService.ts | 43 +++++++++++++++++++ .../status-pages/status-page-test-support.ts | 8 ++-- .../domain/src/mcp-outputs/status-pages.ts | 4 +- 9 files changed, 207 insertions(+), 36 deletions(-) diff --git a/apps/ai/src/mcp/tools/__tests__/status-page-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/status-page-tools.test.ts index a841ad954b..0262ae1113 100644 --- a/apps/ai/src/mcp/tools/__tests__/status-page-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/status-page-tools.test.ts @@ -149,4 +149,47 @@ describe("status page tools", () => { expect(page.isError).toBe(true) expect(markdown(page)).toContain("list_status_pages") }) + + it("reads delayed while the page's monitors have not reported, and live once one has", async () => { + const monitorId = "7f0b7d0e-0b6a-4f55-9a0e-2a4b9a0c1d09" + const now = Date.now() + await executeSql( + rt.testDb, + `INSERT INTO uptime_monitors (id, org_id, name, kind, config_json, created_by, created_at, updated_at, enabled) + VALUES ($1, $2, 'API health', 'http', $3, $4, now(), now(), true)`, + [ + monitorId, + rt.tenant.orgId, + JSON.stringify({ + _tag: "http", + url: "https://example.com/health", + method: "GET", + headers: {}, + assertions: [], + timeoutMs: 5000, + }), + rt.tenant.userId, + ], + ) + await executeSql( + rt.testDb, + `INSERT INTO uptime_monitor_states (monitor_id, org_id, status, recent_json, updated_at) VALUES ($1, $2, 'up', '[]', now())`, + [monitorId, rt.tenant.orgId], + ) + await executeSql(rt.testDb, `UPDATE status_page_components SET monitor_id = $1 WHERE name = 'API'`, [ + monitorId, + ]) + const freshness = async () => + decode(GetStatusPageOutput, await runToolDirect(rt, "get_status_page", { page: "acme-status" })) + .freshness + + expect(await freshness()).toMatchObject({ state: "delayed", updatedAt: null }) + await executeSql( + rt.testDb, + `INSERT INTO uptime_monitor_daily (monitor_id, day, org_id, checks, failures, down_checks, last_check_at) + VALUES ($1, $2, $3, 1, 0, 0, to_timestamp($4 / 1000.0))`, + [monitorId, new Date(now).toISOString().slice(0, 10), rt.tenant.orgId, now - 30_000], + ) + expect((await freshness()).state).toBe("live") + }) }) diff --git a/apps/ai/src/mcp/tools/status-pages.ts b/apps/ai/src/mcp/tools/status-pages.ts index c50100f6e5..edc95739cb 100644 --- a/apps/ai/src/mcp/tools/status-pages.ts +++ b/apps/ai/src/mcp/tools/status-pages.ts @@ -294,11 +294,16 @@ export function registerStatusPageTools(server: McpToolRegistrar) { const incidents = yield* service .listIncidents(tenant.orgId, page.id) .pipe(Effect.mapError(toMcpError("get_status_page"))) - // Answering means Maple is up, so this is the probe side of freshness only. + // Answering means Maple is up, so this is the probe side of freshness only, scoped to this org. const probesSeenAt = yield* builder - .probesSeenAt(nowMs) + .probesSeenAt(nowMs, tenant.orgId) .pipe(Effect.mapError(toMcpError("get_status_page"))) - const freshness = pageFreshness({ snapshot, heartbeat: { at: nowMs, probesSeenAt }, nowMs }) + const monitored = snapshot.components.some((component) => component.monitored) + // No recent probe at all is the most delayed a monitored page can be, not "live now". + const freshness = + monitored && probesSeenAt === null + ? { _tag: "delayed" as const, updatedAt: null } + : pageFreshness({ snapshot, heartbeat: { at: nowMs, probesSeenAt }, nowMs }) return { page: toPageRow(page), overallStatus: overallStatus(snapshot), @@ -319,7 +324,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) { activeIncidents: incidents.map(toIncidentRow), freshness: { state: freshness._tag, - updatedAt: iso(freshness.updatedAt), + updatedAt: freshness.updatedAt === null ? null : iso(freshness.updatedAt), publishPending: page.publishedRevision < page.revision, }, } @@ -329,7 +334,9 @@ export function registerStatusPageTools(server: McpToolRegistrar) { notices: [ ...(output.freshness.state === "delayed" ? [ - `Live checks are delayed: the page's monitors last reported at ${output.freshness.updatedAt}.`, + output.freshness.updatedAt === null + ? "Live checks are delayed: none of the page's monitors has reported in the last two days." + : `Live checks are delayed: the page's monitors last reported at ${output.freshness.updatedAt}.`, ] : []), ...(output.page.lastPublishError === null diff --git a/apps/api/src/routes/v2/status-pages.http.ts b/apps/api/src/routes/v2/status-pages.http.ts index b9bc2b172d..831624c363 100644 --- a/apps/api/src/routes/v2/status-pages.http.ts +++ b/apps/api/src/routes/v2/status-pages.http.ts @@ -13,6 +13,7 @@ import { isoTimestampOrNull, MapleApiV2, paginateArray, + paginateOffsetQuery, V2InsufficientPermissions, type V2StatusPage, type V2StatusPageComponent, @@ -250,8 +251,14 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag // Best effort: the row is gone, so the page must not stay up; a failure is logged. const store = yield* Effect.serviceOption(StatusPageStore) if (Option.isSome(store)) { + // The slug route only while it still names this page: slugs free up for reuse at once. yield* store.value.deletePage(deleted).pipe( - Effect.andThen(store.value.deleteRoutes({ slug: current.slug, hostnames: [] })), + Effect.andThen(store.value.readSlugRoute(current.slug)), + Effect.flatMap((owner) => + owner === deleted + ? store.value.deleteRoutes({ slug: current.slug, hostnames: [] }) + : Effect.void, + ), Effect.catch((error) => Effect.logWarning("Status page edge data not deleted").pipe( Effect.annotateLogs({ @@ -434,9 +441,6 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag }), ) -const byNewest = (a: StatusPageIncidentRecord, b: StatusPageIncidentRecord) => - b.startedAt - a.startedAt || (a.id < b.id ? 1 : -1) - export const HttpV2StatusPageIncidentsLive = HttpApiBuilder.group( MapleApiV2, "statusPageIncidents", @@ -449,25 +453,16 @@ export const HttpV2StatusPageIncidentsLive = HttpApiBuilder.group( Effect.gen(function* () { const tenant = yield* CurrentTenant.Context const state = query.state ?? "open" - const pageIds = - query.page_id === undefined - ? (yield* service.list(tenant.orgId)).map((page) => page.id) - : [query.page_id] - const incidents = yield* Effect.forEach( - pageIds, - (pageId) => - service.listIncidents(tenant.orgId, pageId, { - includeResolved: state !== "open", - }), - { concurrency: 4 }, + const page = yield* paginateOffsetQuery(query, ({ limit, offset }) => + service.listOrgIncidents(tenant.orgId, { + ...(query.page_id === undefined ? {} : { pageId: query.page_id }), + state, + limit, + offset, + }), ) - // `resolvedAt` is set when an incident resolves or maintenance completes. - const filtered = incidents - .flat() - .filter((incident) => state !== "closed" || incident.resolvedAt !== null) - .sort(byNewest) - const page = yield* paginateArray(filtered.map(toV2StatusPageIncident), query) - return { object: "list" as const, ...page } + const data = page.data.map(toV2StatusPageIncident) + return { object: "list" as const, ...page, data } }), ) .handle("retrieve", ({ params }) => diff --git a/packages/backend/src/services/status-pages/StatusPageSnapshotBuilder.test.ts b/packages/backend/src/services/status-pages/StatusPageSnapshotBuilder.test.ts index ebe5190203..67c79aba48 100644 --- a/packages/backend/src/services/status-pages/StatusPageSnapshotBuilder.test.ts +++ b/packages/backend/src/services/status-pages/StatusPageSnapshotBuilder.test.ts @@ -1,6 +1,6 @@ import { afterEach, assert, describe, it } from "@effect/vitest" import { Effect, Exit, Layer } from "effect" -import { StatusPageComponentId, StatusPageIncidentId, UptimeMonitorId } from "@maple/domain/primitives" +import { OrgId, StatusPageComponentId, StatusPageIncidentId, UptimeMonitorId } from "@maple/domain/primitives" import { utcDay } from "@maple/domain/status-page" import { cleanupTestDbs, createTestDb, type TestDb } from "@maple/backend/platform/test-pglite" import { StatusPageSnapshotBuilder, toSnapshot, type SnapshotInput } from "./StatusPageSnapshotBuilder" @@ -8,6 +8,7 @@ import { DAY_MS, newPageId, NOW, + ORG, seedComponent, seedDaily, seedIncident, @@ -298,4 +299,32 @@ describe("StatusPageSnapshotBuilder.build", () => { assert.isTrue(Exit.isFailure(missing)) }).pipe(Effect.provide(StatusPageSnapshotBuilder.layer.pipe(Layer.provide(testDb.layer)))) }) + + it.effect("reads the probe pulse across orgs, or for one org only when asked", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const builder = yield* StatusPageSnapshotBuilder + const ours = crypto.randomUUID() + const theirs = crypto.randomUUID() + yield* seedMonitor(testDb, { id: ours, status: "up" }) + yield* seedMonitor(testDb, { id: theirs, status: "up", orgId: "org_other" }) + const daily = (monitorId: string, lastCheckAt: number, orgId?: string) => + seedDaily(testDb, { + monitorId, + day: utcDay(NOW), + checks: 1, + failures: 0, + downChecks: 0, + lastCheckAt, + ...(orgId === undefined ? {} : { orgId }), + }) + yield* daily(ours, NOW - 600_000) + yield* daily(theirs, NOW - 1_000, "org_other") + + assert.strictEqual(yield* builder.probesSeenAt(NOW), NOW - 1_000) + assert.strictEqual(yield* builder.probesSeenAt(NOW, ORG), NOW - 600_000) + // Another org's fresh probe never vouches for a silent org. + assert.strictEqual(yield* builder.probesSeenAt(NOW, OrgId.make("org_silent")), null) + }).pipe(Effect.provide(StatusPageSnapshotBuilder.layer.pipe(Layer.provide(testDb.layer)))) + }) }) diff --git a/packages/backend/src/services/status-pages/StatusPageSnapshotBuilder.ts b/packages/backend/src/services/status-pages/StatusPageSnapshotBuilder.ts index 6194d98af0..6f75be4961 100644 --- a/packages/backend/src/services/status-pages/StatusPageSnapshotBuilder.ts +++ b/packages/backend/src/services/status-pages/StatusPageSnapshotBuilder.ts @@ -10,6 +10,7 @@ import { UptimeMonitorStates, } from "@maple/db/tables" import type { + OrgId, StatusPageComponentId, StatusPageComponentStatus, StatusPageId, @@ -227,8 +228,14 @@ export interface StatusPageSnapshotBuilderApi { pageId: StatusPageId, nowMs: number, ) => Effect.Effect - /** Newest scheduled check across all monitors (the prober's pulse); null when none ran lately. */ - readonly probesSeenAt: (nowMs: number) => Effect.Effect + /** + * Newest scheduled check (the prober's pulse); null when none ran lately. Across all orgs for the + * edge heartbeat (a system read), or one org's monitors when `orgId` is given. + */ + readonly probesSeenAt: ( + nowMs: number, + orgId?: OrgId, + ) => Effect.Effect } const toDbError = makePersistenceErrorMapper(StatusPagePublishDbError, "Status page snapshot read failed") @@ -376,7 +383,10 @@ export class StatusPageSnapshotBuilder extends Context.Service< return toSnapshot({ ...input, nowMs }) }) - const probesSeenAt = Effect.fn("StatusPageSnapshotBuilder.probesSeenAt")(function* (nowMs: number) { + const probesSeenAt = Effect.fn("StatusPageSnapshotBuilder.probesSeenAt")(function* ( + nowMs: number, + orgId?: OrgId, + ) { // The daily counters fold in every scheduled check; their PK (monitor, day) serves this // per enabled monitor. Heartbeat monitors are excluded: their misses are not probes. const [seen] = yield* dbExecute((db) => @@ -389,6 +399,7 @@ export class StatusPageSnapshotBuilder extends Context.Service< .where(($) => [ $.m.enabled.eq(true), $.m.kind.neq("heartbeat"), + orgId === undefined ? undefined : $.m.orgId.eq(orgId), PG.inList($.day, [utcDay(nowMs), utcDay(nowMs - DAY_MS)]), ]), ), diff --git a/packages/backend/src/services/status-pages/StatusPagesService.test.ts b/packages/backend/src/services/status-pages/StatusPagesService.test.ts index 56d23ebecb..b0a60a4257 100644 --- a/packages/backend/src/services/status-pages/StatusPagesService.test.ts +++ b/packages/backend/src/services/status-pages/StatusPagesService.test.ts @@ -292,6 +292,47 @@ describe("StatusPagesService", () => { }).pipe(Effect.provide(makeLayer(testDb))) }) + it.effect("lists an org's incidents across pages, filtered by state and paged newest first", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const service = yield* StatusPagesService + const first = yield* createPage("first") + const second = yield* createPage("second") + const other = yield* createPage("other-org", OrgId.make("org_status_other")) + const open = (pageId: StatusPageId, title: string, orgId: OrgId = ORG) => + TestClock.adjust("1 minute").pipe( + Effect.andThen( + service.createIncident(orgId, pageId, { + kind: "incident", + title, + impact: "minor", + status: "investigating", + body: title, + componentIds: [], + source: "api", + createdBy: USER, + }), + ), + ) + const a = yield* open(first.id, "A") + yield* open(second.id, "B") + yield* open(first.id, "C") + yield* open(other.id, "Elsewhere", OrgId.make("org_status_other")) + yield* service.postUpdate(ORG, a.id, { status: "resolved", body: "Fixed", source: "api" }) + + const titles = (options: Parameters[1]) => + service.listOrgIncidents(ORG, options).pipe(Effect.map((list) => list.map((i) => i.title))) + assert.deepStrictEqual(yield* titles({ state: "all", limit: 10, offset: 0 }), ["C", "B", "A"]) + assert.deepStrictEqual(yield* titles({ state: "open", limit: 10, offset: 0 }), ["C", "B"]) + assert.deepStrictEqual(yield* titles({ state: "closed", limit: 10, offset: 0 }), ["A"]) + assert.deepStrictEqual(yield* titles({ state: "all", limit: 1, offset: 1 }), ["B"]) + assert.deepStrictEqual(yield* titles({ pageId: first.id, state: "all", limit: 10, offset: 0 }), [ + "C", + "A", + ]) + }).pipe(Effect.provide(makeLayer(testDb))) + }) + it.effect("requires a valid window and maintenance statuses for maintenance", () => { const testDb = createTestDb(trackedDbs) return Effect.gen(function* () { diff --git a/packages/backend/src/services/status-pages/StatusPagesService.ts b/packages/backend/src/services/status-pages/StatusPagesService.ts index ba80d3bdb5..70270642ec 100644 --- a/packages/backend/src/services/status-pages/StatusPagesService.ts +++ b/packages/backend/src/services/status-pages/StatusPagesService.ts @@ -86,6 +86,14 @@ export interface ListStatusPageIncidentsOptions { readonly includeResolved?: boolean } +/** One page of an org's incidents, filtered and ordered in SQL (newest first). */ +export interface ListOrgStatusPageIncidentsOptions { + readonly pageId?: StatusPageId + readonly state: "open" | "closed" | "all" + readonly limit: number + readonly offset: number +} + export interface CreateStatusPageIncidentInput { readonly kind: StatusPageIncidentKind readonly title: string @@ -175,6 +183,10 @@ export interface StatusPagesServiceApi { pageId: StatusPageId, options?: ListStatusPageIncidentsOptions, ) => Effect.Effect, PageError> + readonly listOrgIncidents: ( + orgId: OrgId, + options: ListOrgStatusPageIncidentsOptions, + ) => Effect.Effect, PageError> readonly getIncident: ( orgId: OrgId, incidentId: StatusPageIncidentId, @@ -954,6 +966,36 @@ export class StatusPagesService extends Context.Service + Effect.gen(function* () { + if (pageId !== undefined) yield* findPage(db, orgId, pageId) + const rows = yield* db.run( + PG.from(StatusPageIncidents) + .select() + .where(($) => [ + $.orgId.eq(orgId), + pageId === undefined ? undefined : $.pageId.eq(pageId), + options.state === "open" + ? $.resolvedAt.isNull() + : options.state === "closed" + ? $.resolvedAt.isNotNull() + : undefined, + ]) + .orderBy(["startedAt", "desc"], ["id", "desc"]) + .limit(options.limit) + .offset(options.offset), + ) + return yield* loadIncidents(db, orgId, rows) + }), + ) + }) + const getIncident = Effect.fn("StatusPagesService.getIncident")(function* ( orgId: OrgId, incidentId: StatusPageIncidentId, @@ -1179,6 +1221,7 @@ export class StatusPagesService extends Context.Service sql( @@ -67,7 +68,7 @@ export const seedMonitor = ( VALUES ($1, $2, 'Monitor', $7, $3, $4, $5, $6, $6, $8)`, [ input.id, - ORG, + input.orgId ?? ORG, JSON.stringify({ _tag: "http", url: "https://example.com/health", @@ -88,7 +89,7 @@ export const seedMonitor = ( testDb, `INSERT INTO uptime_monitor_states (monitor_id, org_id, status, recent_json, updated_at) VALUES ($1, $2, $3, '[]', $4)`, - [input.id, ORG, input.status, iso(NOW)], + [input.id, input.orgId ?? ORG, input.status, iso(NOW)], ), ), ) @@ -102,6 +103,7 @@ export const seedDaily = ( readonly failures: number readonly downChecks: number readonly lastCheckAt: number + readonly orgId?: string }, ) => sql( @@ -111,7 +113,7 @@ export const seedDaily = ( [ input.monitorId, input.day, - ORG, + input.orgId ?? ORG, input.checks, input.failures, input.downChecks, diff --git a/packages/domain/src/mcp-outputs/status-pages.ts b/packages/domain/src/mcp-outputs/status-pages.ts index 1626e330cf..2ebaa05ba8 100644 --- a/packages/domain/src/mcp-outputs/status-pages.ts +++ b/packages/domain/src/mcp-outputs/status-pages.ts @@ -70,8 +70,8 @@ export const StatusPageIncidentRow = Schema.Struct({ export const StatusPageFreshnessRow = Schema.Struct({ /** `delayed` when the page's monitors have not reported in the last minutes. */ state: Schema.Literals(["live", "delayed"]), - /** When the page's state was last confirmed by a check result. */ - updatedAt: Schema.String, + /** When the page's state was last confirmed by a check result; null when its monitors never reported lately. */ + updatedAt: Schema.NullOr(Schema.String), /** Whether a change is still waiting for the next publish. */ publishPending: Schema.Boolean, }) From cc6fa1bf58bd1da55ba7c47b2dbec1841c090b69 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 28 Oct 2026 16:51:22 +0100 Subject: [PATCH 06/10] fix(status-pages): explicit page filter instead of conditional spreads --- apps/api/src/routes/v2/status-pages.http.ts | 2 +- .../StatusPageSnapshotBuilder.test.ts | 4 ++-- .../status-pages/StatusPagesService.test.ts | 17 +++++++++++++---- .../services/status-pages/StatusPagesService.ts | 7 ++++--- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/api/src/routes/v2/status-pages.http.ts b/apps/api/src/routes/v2/status-pages.http.ts index 831624c363..b2489acdfe 100644 --- a/apps/api/src/routes/v2/status-pages.http.ts +++ b/apps/api/src/routes/v2/status-pages.http.ts @@ -455,7 +455,7 @@ export const HttpV2StatusPageIncidentsLive = HttpApiBuilder.group( const state = query.state ?? "open" const page = yield* paginateOffsetQuery(query, ({ limit, offset }) => service.listOrgIncidents(tenant.orgId, { - ...(query.page_id === undefined ? {} : { pageId: query.page_id }), + pageId: query.page_id ?? null, state, limit, offset, diff --git a/packages/backend/src/services/status-pages/StatusPageSnapshotBuilder.test.ts b/packages/backend/src/services/status-pages/StatusPageSnapshotBuilder.test.ts index 67c79aba48..f2092d8ef2 100644 --- a/packages/backend/src/services/status-pages/StatusPageSnapshotBuilder.test.ts +++ b/packages/backend/src/services/status-pages/StatusPageSnapshotBuilder.test.ts @@ -308,7 +308,7 @@ describe("StatusPageSnapshotBuilder.build", () => { const theirs = crypto.randomUUID() yield* seedMonitor(testDb, { id: ours, status: "up" }) yield* seedMonitor(testDb, { id: theirs, status: "up", orgId: "org_other" }) - const daily = (monitorId: string, lastCheckAt: number, orgId?: string) => + const daily = (monitorId: string, lastCheckAt: number, orgId: string = ORG) => seedDaily(testDb, { monitorId, day: utcDay(NOW), @@ -316,7 +316,7 @@ describe("StatusPageSnapshotBuilder.build", () => { failures: 0, downChecks: 0, lastCheckAt, - ...(orgId === undefined ? {} : { orgId }), + orgId, }) yield* daily(ours, NOW - 600_000) yield* daily(theirs, NOW - 1_000, "org_other") diff --git a/packages/backend/src/services/status-pages/StatusPagesService.test.ts b/packages/backend/src/services/status-pages/StatusPagesService.test.ts index b0a60a4257..6119dfbaf7 100644 --- a/packages/backend/src/services/status-pages/StatusPagesService.test.ts +++ b/packages/backend/src/services/status-pages/StatusPagesService.test.ts @@ -322,10 +322,19 @@ describe("StatusPagesService", () => { const titles = (options: Parameters[1]) => service.listOrgIncidents(ORG, options).pipe(Effect.map((list) => list.map((i) => i.title))) - assert.deepStrictEqual(yield* titles({ state: "all", limit: 10, offset: 0 }), ["C", "B", "A"]) - assert.deepStrictEqual(yield* titles({ state: "open", limit: 10, offset: 0 }), ["C", "B"]) - assert.deepStrictEqual(yield* titles({ state: "closed", limit: 10, offset: 0 }), ["A"]) - assert.deepStrictEqual(yield* titles({ state: "all", limit: 1, offset: 1 }), ["B"]) + assert.deepStrictEqual(yield* titles({ pageId: null, state: "all", limit: 10, offset: 0 }), [ + "C", + "B", + "A", + ]) + assert.deepStrictEqual(yield* titles({ pageId: null, state: "open", limit: 10, offset: 0 }), [ + "C", + "B", + ]) + assert.deepStrictEqual(yield* titles({ pageId: null, state: "closed", limit: 10, offset: 0 }), [ + "A", + ]) + assert.deepStrictEqual(yield* titles({ pageId: null, state: "all", limit: 1, offset: 1 }), ["B"]) assert.deepStrictEqual(yield* titles({ pageId: first.id, state: "all", limit: 10, offset: 0 }), [ "C", "A", diff --git a/packages/backend/src/services/status-pages/StatusPagesService.ts b/packages/backend/src/services/status-pages/StatusPagesService.ts index 70270642ec..5186ac1e89 100644 --- a/packages/backend/src/services/status-pages/StatusPagesService.ts +++ b/packages/backend/src/services/status-pages/StatusPagesService.ts @@ -88,7 +88,8 @@ export interface ListStatusPageIncidentsOptions { /** One page of an org's incidents, filtered and ordered in SQL (newest first). */ export interface ListOrgStatusPageIncidentsOptions { - readonly pageId?: StatusPageId + /** One page, or null for every page of the org. */ + readonly pageId: StatusPageId | null readonly state: "open" | "closed" | "all" readonly limit: number readonly offset: number @@ -974,13 +975,13 @@ export class StatusPagesService extends Context.Service Effect.gen(function* () { - if (pageId !== undefined) yield* findPage(db, orgId, pageId) + if (pageId !== null) yield* findPage(db, orgId, pageId) const rows = yield* db.run( PG.from(StatusPageIncidents) .select() .where(($) => [ $.orgId.eq(orgId), - pageId === undefined ? undefined : $.pageId.eq(pageId), + pageId === null ? undefined : $.pageId.eq(pageId), options.state === "open" ? $.resolvedAt.isNull() : options.state === "closed" From 8b0a113ab6a905dcb57741e71bb10705ec5b75a1 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 28 Oct 2026 16:53:02 +0100 Subject: [PATCH 07/10] chore(status-pages): regenerate the iOS OpenAPI spec for the new tags --- apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index d094bb83f3..b6c0f9d803 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -14004,6 +14004,14 @@ "description": "Checks Maple runs from outside your infrastructure (HTTP, TCP, DNS) and heartbeats your jobs send. Each check is a span in your traces; a monitor goes down after enough failures in a row.", "name": "Uptime Monitors" }, + { + "description": "Public status pages for your customers, served from edge storage so they stay up when Maple does not. Components follow your uptime monitors.", + "name": "Status Pages" + }, + { + "description": "Customer-facing incidents and scheduled maintenance on your status pages, separate from internal alert incidents.", + "name": "Status Page Incidents" + }, { "description": "Automatically detected instrumentation improvements — attribute renames toward canonical semantic conventions, double emissions, and naming violations. List them, and dismiss or reopen individual recommendations.", "name": "Instrumentation Recommendations" From 1a386a960960f0d8c2d9df66e03f5a20fc22fa31 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 28 Oct 2026 17:02:12 +0100 Subject: [PATCH 08/10] fix(status-pages): get_status_page notes the update tools instead of a half-filled call --- .../tools/__tests__/status-page-tools.test.ts | 8 ++++---- apps/ai/src/mcp/tools/status-pages.ts | 16 ++++++---------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/apps/ai/src/mcp/tools/__tests__/status-page-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/status-page-tools.test.ts index 0262ae1113..72690df9e2 100644 --- a/apps/ai/src/mcp/tools/__tests__/status-page-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/status-page-tools.test.ts @@ -85,10 +85,10 @@ describe("status page tools", () => { }), ) expect(updated.incident).toMatchObject({ status: "identified", published: true, updateCount: 2 }) - const live = decode( - GetStatusPageOutput, - await runToolDirect(rt, "get_status_page", { page: "Acme Status" }), - ) + const liveResult = await runToolDirect(rt, "get_status_page", { page: "Acme Status" }) + // The follow-up needs public wording, so it is a notice, not a half-filled call. + expect(markdown(liveResult)).toContain("To update an open incident, call post_status_page_update") + const live = decode(GetStatusPageOutput, liveResult) expect(live.components).toMatchObject([{ name: "API", status: "partial_outage" }]) expect(live.overallStatus).toBe("partial_outage") diff --git a/apps/ai/src/mcp/tools/status-pages.ts b/apps/ai/src/mcp/tools/status-pages.ts index edc95739cb..1f5c39c436 100644 --- a/apps/ai/src/mcp/tools/status-pages.ts +++ b/apps/ai/src/mcp/tools/status-pages.ts @@ -332,6 +332,12 @@ export function registerStatusPageTools(server: McpToolRegistrar) { render: (output) => ({ title: `Status page: ${output.page.name}`, notices: [ + // No suggested call: both follow-ups need a public message only the user can word. + ...(output.activeIncidents.some((incident) => isActiveIncident(incident)) + ? [ + "To update an open incident, call post_status_page_update (incident, status, message) or resolve_status_page_incident (incident, message) with wording the user approves.", + ] + : []), ...(output.freshness.state === "delayed" ? [ output.freshness.updatedAt === null @@ -386,16 +392,6 @@ export function registerStatusPageTools(server: McpToolRegistrar) { ), ]), ], - next: output.activeIncidents - .filter((incident) => isActiveIncident(incident)) - .slice(0, 2) - .map((incident) => - doc.next( - "post_status_page_update", - { incident: incident.id }, - `post the next update on "${incident.title}"`, - ), - ), }), }) From 568642fe49bee12176fdb1c977864227f3f9529b Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 28 Oct 2026 17:05:12 +0100 Subject: [PATCH 09/10] fix(status-pages): post_status_page_update publishes in the same write as the update --- apps/ai/src/mcp/tools/status-pages.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/apps/ai/src/mcp/tools/status-pages.ts b/apps/ai/src/mcp/tools/status-pages.ts index 1f5c39c436..39d67be4d4 100644 --- a/apps/ai/src/mcp/tools/status-pages.ts +++ b/apps/ai/src/mcp/tools/status-pages.ts @@ -482,16 +482,11 @@ export function registerStatusPageTools(server: McpToolRegistrar) { ...(componentIds === undefined ? undefined : { componentIds }), source: "mcp", createdBy: tenant.userId, + // One write: a failure leaves neither the update nor the publish, so a retry cannot double post. + publish: params.publish === true, }) .pipe(Effect.mapError(toMcpError(tool))) - // Published only once the update itself was accepted. - const updated = - params.publish === true && !posted.published - ? yield* service - .updateIncident(tenant.orgId, incidentId, { published: true }) - .pipe(Effect.mapError(toMcpError(tool))) - : posted - return { action: "updated" as const, incident: toIncidentRow(updated) } + return { action: "updated" as const, incident: toIncidentRow(posted) } }), render: (output) => renderIncident("Status page update posted", output), }) From 90344b5a7535f198c11ddb74ee33a2e918c8c28c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 28 Oct 2026 17:13:46 +0100 Subject: [PATCH 10/10] fix(status-pages): resolve_status_page_incident can publish a draft, closed drafts get their own notice --- .../tools/__tests__/status-page-tools.test.ts | 32 +++++++++++++++++++ apps/ai/src/mcp/tools/status-pages.ts | 11 +++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/apps/ai/src/mcp/tools/__tests__/status-page-tools.test.ts b/apps/ai/src/mcp/tools/__tests__/status-page-tools.test.ts index 72690df9e2..e219db30e4 100644 --- a/apps/ai/src/mcp/tools/__tests__/status-page-tools.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/status-page-tools.test.ts @@ -150,6 +150,38 @@ describe("status page tools", () => { expect(markdown(page)).toContain("list_status_pages") }) + it("resolves a draft and publishes it in the same call, or says it stayed a draft", async () => { + const open = async (title: string) => + decode( + StatusPageIncidentMutationOutput, + await runToolDirect(rt, "create_status_page_incident", { + page: "acme-status", + title, + message: "Investigating.", + }), + ).incident.id + const kept = await runToolDirect(rt, "resolve_status_page_incident", { + incident: await open("Quiet draft"), + message: "Never mind.", + }) + expect(decode(StatusPageIncidentMutationOutput, kept).incident).toMatchObject({ + status: "resolved", + published: false, + }) + expect(markdown(kept)).toContain("closed as a draft") + expect(markdown(kept)).not.toContain("post_status_page_update") + + const shown = decode( + StatusPageIncidentMutationOutput, + await runToolDirect(rt, "resolve_status_page_incident", { + incident: await open("Public draft"), + message: "Fixed.", + publish: true, + }), + ) + expect(shown.incident).toMatchObject({ status: "resolved", published: true }) + }) + it("reads delayed while the page's monitors have not reported, and live once one has", async () => { const monitorId = "7f0b7d0e-0b6a-4f55-9a0e-2a4b9a0c1d09" const now = Date.now() diff --git a/apps/ai/src/mcp/tools/status-pages.ts b/apps/ai/src/mcp/tools/status-pages.ts index 39d67be4d4..6a802daacf 100644 --- a/apps/ai/src/mcp/tools/status-pages.ts +++ b/apps/ai/src/mcp/tools/status-pages.ts @@ -175,9 +175,12 @@ const renderIncident = (title: string, output: typeof StatusPageIncidentMutation title, notices: incident.published ? [] - : [ - "This is a draft: it is not on the public page. post_status_page_update with publish=true publishes it.", - ], + : incident.resolvedAt === null + ? [ + "This is a draft: it is not on the public page. post_status_page_update with publish=true publishes it.", + ] + : // A closed draft must not be pointed at post_status_page_update: its statuses reopen it. + ["This was closed as a draft, so it never appeared on the public page."], blocks: [ doc.fields([ ["Title", incident.title], @@ -499,6 +502,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) { parameters: Schema.Struct({ incident: P.text("The incident's id (sti_… or its UUID)"), message: P.text("The closing public update, e.g. what was fixed"), + publish: P.optionalFlag("true publishes a draft along with its closing update"), }), output: StatusPageIncidentMutationOutput, hints: MUTATION_HINTS, @@ -518,6 +522,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) { body: params.message, source: "mcp", createdBy: tenant.userId, + publish: params.publish === true, }) .pipe(Effect.mapError(toMcpError(tool))) return { action: "resolved" as const, incident: toIncidentRow(updated) }