diff --git a/apps/ai/src/mcp/__evals__/eval-runtime.ts b/apps/ai/src/mcp/__evals__/eval-runtime.ts index 6e2b86a000..6070bddb26 100644 --- a/apps/ai/src/mcp/__evals__/eval-runtime.ts +++ b/apps/ai/src/mcp/__evals__/eval-runtime.ts @@ -1,5 +1,6 @@ import { ConfigProvider, Effect, Layer, ManagedRuntime, Schema } from "effect" -import { McpServicesLive } from "../../runtime/mcp-service-graph" +import { mcpServicesWithFeatureFlags } from "../../runtime/mcp-service-graph" +import { OrganizationFeatureFlagsService } from "@maple/backend/services/org/OrganizationFeatureFlagsService" import { Env } from "@maple/backend/platform/Env" import { WorkerEnvironment } from "@maple/infra/worker-runtime" import { createTestDb, type TestDb } from "@maple/backend/platform/test-pglite" @@ -43,7 +44,13 @@ export interface EvalRuntime { * carries the already-resolved MCP tenant, matching the post-auth dispatcher * context shared by HTTP and RPC. */ -export const makeEvalRuntime = (): EvalRuntime => { +export const makeEvalRuntime = ( + featureFlags: Layer.Layer< + OrganizationFeatureFlagsService, + never, + Env + > = OrganizationFeatureFlagsService.layer, +): EvalRuntime => { const testDb = createTestDb() const env = testEnv() @@ -52,7 +59,7 @@ export const makeEvalRuntime = (): EvalRuntime => { const databaseLive = testDb.layer const workerEnvLive = Layer.succeed(WorkerEnvironment, env as Record) - const layer = McpServicesLive.pipe( + const layer = mcpServicesWithFeatureFlags(featureFlags).pipe( Layer.provide(Layer.mergeAll(configLive, envLive, databaseLive, workerEnvLive)), ) // `as any`: the residual requirement set is satisfied at runtime (same pattern 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 eabecf3d6f..90a39656b2 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 @@ -8,7 +8,9 @@ import { ListStatusPagesOutput, StatusPageIncidentMutationOutput, } from "@maple/domain/mcp-outputs" +import { DISABLED_ORGANIZATION_FEATURE_FLAGS } from "@maple/domain/organization-feature-flags" import { executeSql } from "@maple/backend/platform/test-pglite" +import { OrganizationFeatureFlagsService } from "@maple/backend/services/org/OrganizationFeatureFlagsService" import type { McpToolResult } from "../types" import { makeEvalRuntime, markdown, runToolDirect, type EvalRuntime } from "../../__evals__/eval-runtime" @@ -117,6 +119,26 @@ describe("status page tools", () => { }), ) expect(created.incident.published).toBe(true) + expect(created.pageDraft).toBe(false) + }) + + it("warns that a published incident stays private while its page is a draft", async () => { + await executeSql( + rt.testDb, + `INSERT INTO status_pages (id, org_id, name, slug, draft, created_by, created_at, updated_at) + VALUES ($1, $2, 'Draft Status', 'draft-status', true, $3, now(), now())`, + ["7f0b7d0e-0b6a-4f55-9a0e-2a4b9a0c1d0a", rt.tenant.orgId, rt.tenant.userId], + ) + const result = await runToolDirect(rt, "create_status_page_incident", { + page: "draft-status", + title: "Login delays", + message: "Some sign-ins are slow.", + publish: true, + }) + const created = decode(StatusPageIncidentMutationOutput, result) + expect(created.incident.published).toBe(true) + expect(created.pageDraft).toBe(true) + expect(markdown(result)).toContain("The page is a draft") }) it("schedules maintenance as a draft with its window", async () => { @@ -400,3 +422,47 @@ describe("status page tools", () => { }) }) }) + +describe("status page tools outside the statusPages rollout", () => { + let off: EvalRuntime + beforeAll(() => { + off = makeEvalRuntime(OrganizationFeatureFlagsService.fixed(DISABLED_ORGANIZATION_FEATURE_FLAGS)) + }) + afterAll(async () => { + await off.dispose() + }) + + it("refuses every tool as unavailable, naming the missing capability", async () => { + // A real document, so the apply is refused by the gate and not by decoding. + const config = decode( + GetStatusPageConfigOutput, + await runToolDirect(rt, "get_status_page_config", { page: "acme-status" }), + ).config + const incident = "7f0b7d0e-0b6a-4f55-9a0e-2a4b9a0c1d09" + const calls: ReadonlyArray]> = [ + ["list_status_pages", {}], + ["get_status_page", { page: "acme-status" }], + ["create_status_page_incident", { page: "acme-status", title: "x", message: "y" }], + ["post_status_page_update", { incident, status: "identified", message: "y" }], + ["resolve_status_page_incident", { incident, message: "y" }], + [ + "schedule_status_page_maintenance", + { + page: "acme-status", + title: "x", + message: "y", + starts_at: "2026-08-01T02:00:00Z", + ends_at: "2026-08-01T03:00:00Z", + }, + ], + ["get_status_page_config", { page: "acme-status" }], + ["apply_status_page_config", { page: "acme-status", config }], + ] + for (const [name, params] of calls) { + const result: McpToolResult = await runToolDirect(off, name, params) + expect(result.isError).toBe(true) + expect(result.failureCategory).toBe("unavailable") + expect(markdown(result)).toContain("not enabled for this organization") + } + }) +}) diff --git a/apps/ai/src/mcp/tools/runtime-requirements.ts b/apps/ai/src/mcp/tools/runtime-requirements.ts index 2fda1076e3..00819d9064 100644 --- a/apps/ai/src/mcp/tools/runtime-requirements.ts +++ b/apps/ai/src/mcp/tools/runtime-requirements.ts @@ -19,6 +19,7 @@ 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 { OrganizationFeatureFlagsService } from "@maple/backend/services/org/OrganizationFeatureFlagsService" 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" @@ -55,6 +56,7 @@ export type McpToolRuntimeRequirements = | SetupAuditService | StatusPageSnapshotBuilder | StatusPagesService + | OrganizationFeatureFlagsService | UptimeMonitorsService | IngestRuleService | VcsCommitService diff --git a/apps/ai/src/mcp/tools/status-pages.ts b/apps/ai/src/mcp/tools/status-pages.ts index b8d4e10baa..f986418494 100644 --- a/apps/ai/src/mcp/tools/status-pages.ts +++ b/apps/ai/src/mcp/tools/status-pages.ts @@ -30,14 +30,16 @@ import { StatusPageIncidentImpact, type OrgId, type StatusPageComponentId, + type StatusPageId, type StatusPageIncidentStatus, } from "@maple/domain/primitives" import { isActiveIncident, overallStatus, pageFreshness } from "@maple/domain/status-page" import { parseWarehouseDateTime, WarehouseTimeInput } from "@maple/query-engine" import { AuditLogService } from "@maple/backend/services/audit/AuditLogService" -import { auditAttribution } from "@maple/backend/services/audit/audit-access" +import { auditAttribution, originMetadata } from "@maple/backend/services/audit/audit-access" import { CurrentAuditActor } from "@maple/backend/services/auth/audit-actor" import { requireAdmin } from "@maple/backend/services/auth/auth" +import { OrganizationFeatureFlagsService } from "@maple/backend/services/org/OrganizationFeatureFlagsService" import { StatusPagesService } from "@maple/backend/services/status-pages/StatusPagesService" import { StatusPageSnapshotBuilder } from "@maple/backend/services/status-pages/StatusPageSnapshotBuilder" import { McpInvalidInputError, McpQueryError, McpUnavailableError, type McpToolRegistrar } from "./types" @@ -123,6 +125,59 @@ const toIncidentRow = (incident: StatusPageIncidentRecord): typeof StatusPageInc } } +/** The caller's tenant, once its org is in the `statusPages` rollout. */ +const tenantInRollout = Effect.fnUntraced(function* (tool: string) { + const tenant = yield* CurrentMcpTenant + const flags = yield* (yield* OrganizationFeatureFlagsService).flags(tenant.orgId) + if (!flags.statusPages) { + return yield* new McpUnavailableError({ + message: `${tool} needs status pages, which are not enabled for this organization.`, + capability: "status_pages", + }) + } + return tenant +}) + +/** The same resource entries the REST routes write, so MCP edits show up per incident. */ +const auditIncidentCreated = Effect.fnUntraced(function* (incident: StatusPageIncidentRecord) { + const tenant = yield* CurrentMcpTenant + yield* (yield* AuditLogService).record({ + orgId: tenant.orgId, + ...auditAttribution(tenant, yield* CurrentAuditActor), + action: "status_page_incident.created", + resourceId: incident.id, + metadata: { + title: incident.title, + kind: incident.kind, + page_id: incident.pageId, + published: incident.published, + ...originMetadata(tenant.turnOrigin), + }, + }) +}) + +const auditUpdatePosted = Effect.fnUntraced(function* (incident: StatusPageIncidentRecord) { + const tenant = yield* CurrentMcpTenant + yield* (yield* AuditLogService).record({ + orgId: tenant.orgId, + ...auditAttribution(tenant, yield* CurrentAuditActor), + action: "status_page_incident.update_posted", + resourceId: incident.id, + metadata: { + status: incident.status, + page_id: incident.pageId, + active: isActiveIncident(incident), + ...originMetadata(tenant.turnOrigin), + }, + }) +}) + +/** Whether the incident's page is still a draft, which keeps everything on it private. */ +const pageIsDraft = Effect.fnUntraced(function* (tool: string, orgId: OrgId, pageId: StatusPageId) { + const page = yield* (yield* StatusPagesService).get(orgId, pageId).pipe(Effect.mapError(toMcpError(tool))) + return page.draft +}) + /** 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 @@ -193,14 +248,18 @@ const renderIncident = (title: string, output: typeof StatusPageIncidentMutation const incident = output.incident return { title, - notices: incident.published - ? [] - : 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."], + notices: output.pageDraft + ? [ + "The page is a draft, so nothing on it is public, this incident included. Publish the page in Maple under Status pages.", + ] + : incident.published + ? [] + : 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], @@ -243,7 +302,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) { hints: { readOnly: true, destructive: false, idempotent: true }, phrases: ["Checking status pages"], handler: Effect.fn("McpTool.listStatusPages")(function* () { - const tenant = yield* CurrentMcpTenant + const tenant = yield* tenantInRollout("list_status_pages") const service = yield* StatusPagesService const pages = yield* service .list(tenant.orgId) @@ -306,7 +365,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) { hints: { readOnly: true, destructive: false, idempotent: true }, phrases: ["Reading a status page"], handler: Effect.fn("McpTool.getStatusPage")(function* (params) { - const tenant = yield* CurrentMcpTenant + const tenant = yield* tenantInRollout("get_status_page") const service = yield* StatusPagesService const builder = yield* StatusPageSnapshotBuilder const page = yield* resolvePage("get_status_page", tenant.orgId, params.page) @@ -446,7 +505,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) { phrases: ["Opening a status page incident"], handler: Effect.fn("McpTool.createStatusPageIncident")(function* (params) { const tool = "create_status_page_incident" - const tenant = yield* CurrentMcpTenant + const tenant = yield* tenantInRollout(tool) const service = yield* StatusPagesService const page = yield* resolvePage(tool, tenant.orgId, params.page) const components = yield* pageComponents(tool, tenant.orgId, page.id) @@ -464,7 +523,8 @@ export function registerStatusPageTools(server: McpToolRegistrar) { createdBy: tenant.userId, }) .pipe(Effect.mapError(toMcpError(tool))) - return { action: "created" as const, incident: toIncidentRow(created) } + yield* auditIncidentCreated(created) + return { action: "created" as const, incident: toIncidentRow(created), pageDraft: page.draft } }), render: (output) => renderIncident("Status page incident opened", output), }) @@ -490,7 +550,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) { phrases: ["Posting a status page update"], handler: Effect.fn("McpTool.postStatusPageUpdate")(function* (params) { const tool = "post_status_page_update" - const tenant = yield* CurrentMcpTenant + const tenant = yield* tenantInRollout(tool) const service = yield* StatusPagesService const incidentId = yield* decodeIncidentId(params.incident) const current = yield* service @@ -515,7 +575,12 @@ export function registerStatusPageTools(server: McpToolRegistrar) { publish: params.publish === true, }) .pipe(Effect.mapError(toMcpError(tool))) - return { action: "updated" as const, incident: toIncidentRow(posted) } + yield* auditUpdatePosted(posted) + return { + action: "updated" as const, + incident: toIncidentRow(posted), + pageDraft: yield* pageIsDraft(tool, tenant.orgId, posted.pageId), + } }), render: (output) => renderIncident("Status page update posted", output), }) @@ -535,7 +600,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) { phrases: ["Resolving a status page incident"], handler: Effect.fn("McpTool.resolveStatusPageIncident")(function* (params) { const tool = "resolve_status_page_incident" - const tenant = yield* CurrentMcpTenant + const tenant = yield* tenantInRollout(tool) const service = yield* StatusPagesService const incidentId = yield* decodeIncidentId(params.incident) const current = yield* service @@ -551,7 +616,12 @@ export function registerStatusPageTools(server: McpToolRegistrar) { publish: params.publish === true, }) .pipe(Effect.mapError(toMcpError(tool))) - return { action: "resolved" as const, incident: toIncidentRow(updated) } + yield* auditUpdatePosted(updated) + return { + action: "resolved" as const, + incident: toIncidentRow(updated), + pageDraft: yield* pageIsDraft(tool, tenant.orgId, updated.pageId), + } }), render: (output) => renderIncident("Status page incident resolved", output), }) @@ -576,7 +646,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) { phrases: ["Scheduling maintenance"], handler: Effect.fn("McpTool.scheduleStatusPageMaintenance")(function* (params) { const tool = "schedule_status_page_maintenance" - const tenant = yield* CurrentMcpTenant + const tenant = yield* tenantInRollout(tool) const service = yield* StatusPagesService const page = yield* resolvePage(tool, tenant.orgId, params.page) const components = yield* pageComponents(tool, tenant.orgId, page.id) @@ -596,7 +666,8 @@ export function registerStatusPageTools(server: McpToolRegistrar) { createdBy: tenant.userId, }) .pipe(Effect.mapError(toMcpError(tool))) - return { action: "scheduled" as const, incident: toIncidentRow(created) } + yield* auditIncidentCreated(created) + return { action: "scheduled" as const, incident: toIncidentRow(created), pageDraft: page.draft } }), render: (output) => renderIncident("Maintenance scheduled", output), }) @@ -614,7 +685,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) { phrases: ["Reading a status page config"], handler: Effect.fn("McpTool.getStatusPageConfig")(function* (params) { const tool = "get_status_page_config" - const tenant = yield* CurrentMcpTenant + const tenant = yield* tenantInRollout(tool) const service = yield* StatusPagesService const page = yield* resolvePage(tool, tenant.orgId, params.page) const config = yield* service @@ -652,7 +723,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) { phrases: ["Applying a status page config"], handler: Effect.fn("McpTool.applyStatusPageConfig")(function* (params) { const tool = "apply_status_page_config" - const tenant = yield* CurrentMcpTenant + const tenant = yield* tenantInRollout(tool) yield* requireAdmin( tenant.roles, () => @@ -683,6 +754,7 @@ export function registerStatusPageTools(server: McpToolRegistrar) { components_updated: changes.updated.length, components_deleted: changes.deleted.length, components_reordered: changes.reordered, + ...originMetadata(tenant.turnOrigin), }, }) const applied = yield* service.get(tenant.orgId, page.id).pipe(Effect.mapError(toMcpError(tool))) @@ -701,7 +773,9 @@ export function registerStatusPageTools(server: McpToolRegistrar) { title: `Status page config applied: ${output.page.name}`, notices: unchanged ? ["Nothing changed: the document matched the page."] - : ["The change publishes to the public page within a minute."], + : output.page.draft + ? ["The page is a draft, so the change stays private until the page is published."] + : ["The change publishes to the public page within a minute."], blocks: [ doc.fields([ ["Fields changed", list(fields)], diff --git a/apps/ai/src/runtime/mcp-service-graph.ts b/apps/ai/src/runtime/mcp-service-graph.ts index 948480e3fc..d99b6a1ba3 100644 --- a/apps/ai/src/runtime/mcp-service-graph.ts +++ b/apps/ai/src/runtime/mcp-service-graph.ts @@ -6,13 +6,21 @@ import { Env } from "@maple/backend/platform/Env" import { ErrorActorsService } from "@maple/backend/services/errors/ErrorActorsService" import { OrgMembershipService } from "@maple/backend/services/auth/OrgMembershipService" import { InvestigationService } from "@maple/backend/services/errors/InvestigationService" +import { OrganizationFeatureFlagsService } from "@maple/backend/services/org/OrganizationFeatureFlagsService" import { OrgOpenRouterKeyService } from "@maple/backend/services/org/OrgOpenRouterKeyService" import { PrReviewService } from "@maple/backend/services/pr-review/PrReviewService" import { PrReviewConversationService } from "@maple/backend/services/pr-review/PrReviewConversationService" -export const McpServicesLive = McpToolExecutor.layer.pipe( - Layer.provide(Layer.mergeAll(Env.layer, EdgeCacheServiceLive)), -) +/** The tool executor over the given org rollout flags; tests pass a fixed answer. */ +export const mcpServicesWithFeatureFlags = ( + featureFlags: Layer.Layer, +) => + McpToolExecutor.layer.pipe( + Layer.provide(featureFlags), + Layer.provide(Layer.mergeAll(Env.layer, EdgeCacheServiceLive)), + ) + +export const McpServicesLive = mcpServicesWithFeatureFlags(OrganizationFeatureFlagsService.layer) /** * What applying a proposal somebody approved from a chat platform needs, and nothing else. diff --git a/apps/ai/test/mcp/unimplemented-tool-services.ts b/apps/ai/test/mcp/unimplemented-tool-services.ts index d81f2166af..a3472b25d8 100644 --- a/apps/ai/test/mcp/unimplemented-tool-services.ts +++ b/apps/ai/test/mcp/unimplemented-tool-services.ts @@ -23,6 +23,7 @@ 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 { OrganizationFeatureFlagsService } from "@maple/backend/services/org/OrganizationFeatureFlagsService" 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" @@ -54,6 +55,7 @@ export const unimplementedMcpToolServices = Layer.mergeAll( Layer.mock(IngestRuleService)({}), Layer.mock(StatusPagesService)({}), Layer.mock(StatusPageSnapshotBuilder)({}), + Layer.mock(OrganizationFeatureFlagsService)({}), Layer.mock(RepoSandboxService)({}), Layer.mock(QueryEngineService)({}), WarehouseQueryService.layer, diff --git a/apps/api/src/routes/v2/status-pages.http.test.ts b/apps/api/src/routes/v2/status-pages.http.test.ts index 69cd936653..ffccbb6ee7 100644 --- a/apps/api/src/routes/v2/status-pages.http.test.ts +++ b/apps/api/src/routes/v2/status-pages.http.test.ts @@ -7,6 +7,8 @@ import { MapleApiV2, UptimeMonitorPublicId } from "@maple/domain/http/v2" import { UptimeMonitorId, type StatusPageId } from "@maple/domain/primitives" import { utcDay } from "@maple/domain/status-page" import { cleanupTestDbs, createTestDb, executeSql, type TestDb } from "@maple/backend/platform/test-pglite" +import { DISABLED_ORGANIZATION_FEATURE_FLAGS } from "@maple/domain/organization-feature-flags" +import { OrganizationFeatureFlagsService } from "@maple/backend/services/org/OrganizationFeatureFlagsService" import { StatusPagePublisher } from "@maple/backend/services/status-pages/StatusPagePublisher" import { StatusPagePublishDbError } from "@maple/backend/services/status-pages/publish-errors" import { Env } from "@maple/backend/platform/Env" @@ -21,7 +23,7 @@ import { MobileDevicesService } from "@maple/backend/services/push/MobileDevices import { V2TransportErrorBoundaryLive } from "./error-envelope" import { AlertsServiceStubLayer, - allV2GroupLayersWithAudit, + allV2GroupLayersWithFeatureFlags, ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, Phase1ResourceStubsLayer, @@ -70,7 +72,10 @@ const fakePublisher = (published: Array, options: { readonly fail? heartbeat: () => Effect.void, }) -const makeHarness = (publisher?: Layer.Layer) => { +const makeHarness = ( + publisher?: Layer.Layer, + featureFlags: Layer.Layer = OrganizationFeatureFlagsService.allEnabled, +) => { const testDb = createTestDb(createdDbs) const audit = makeMemoryAuditLog() const envLive = Env.layer.pipe(Layer.provide(testConfig())) @@ -84,7 +89,7 @@ const makeHarness = (publisher?: Layer.Layer) => { ).pipe(Layer.provideMerge(Layer.mergeAll(envLive, testDb.layer))) const routes = HttpApiBuilder.layer(MapleApiV2).pipe( - Layer.provide(allV2GroupLayersWithAudit(Layer.succeed(AuditLogService, audit))), + Layer.provide(allV2GroupLayersWithFeatureFlags(featureFlags, Layer.succeed(AuditLogService, audit))), Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(AlertsServiceStubLayer), Layer.provide(ConfigResourceServiceStubsLayer), @@ -144,10 +149,11 @@ const makeHarness = (publisher?: Layer.Layer) => { const withHarness = ( body: (harness: ReturnType) => Promise, publisher?: Layer.Layer, + featureFlags?: Layer.Layer, ) => Effect.runPromise( Effect.acquireUseRelease( - Effect.sync(() => makeHarness(publisher)), + Effect.sync(() => makeHarness(publisher, featureFlags)), (harness) => Effect.promise(() => body(harness)), (harness) => Effect.promise(() => harness.dispose()), ), @@ -473,6 +479,56 @@ describe("v2 status pages", () => { expect(noWindow.status).toBe(400) })) + describe("statusPages rollout", () => { + const flagged = (statusPages: boolean) => + OrganizationFeatureFlagsService.fixed({ ...DISABLED_ORGANIZATION_FEATURE_FLAGS, statusPages }) + + it("refuses every page and incident route with 403 status_pages_disabled when the org is out", () => + withHarness( + async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const calls = [ + harness.request("GET", "/v2/status_pages", token), + harness.request("POST", "/v2/status_pages", token, { + name: "Acme", + slug: "acme-off", + }), + harness.request("POST", "/v2/status_pages/preview", token, { + name: "Acme", + slug: "acme", + components: [{ name: "Docs" }], + }), + harness.request("GET", "/v2/status_page_incidents", token), + ] + for (const response of await Promise.all(calls)) { + expect(response.status).toBe(403) + expect(response.body.error).toMatchObject({ code: "status_pages_disabled" }) + } + }, + undefined, + flagged(false), + )) + + it("serves the routes once the org is in", () => + withHarness( + async (harness) => { + const { secret: token } = await harness.bootstrapKey() + const created = await harness.request("POST", "/v2/status_pages", token, { + name: "Acme", + slug: "acme-on", + }) + expect(created.status).toBe(200) + const listed = await harness.request("GET", "/v2/status_pages", token) + expect(listed.body.data.map((page: { id: string }) => page.id)).toEqual([created.body.id]) + expect((await harness.request("GET", "/v2/status_page_incidents", token)).status).toBe( + 200, + ) + }, + undefined, + flagged(true), + )) + }) + it("refuses page and component writes from members", () => withHarness(async (harness) => { const { secret: member } = await harness.bootstrapKey(ORG, MEMBER) diff --git a/apps/api/src/routes/v2/status-pages.http.ts b/apps/api/src/routes/v2/status-pages.http.ts index a11f19f0ae..461f23ab8d 100644 --- a/apps/api/src/routes/v2/status-pages.http.ts +++ b/apps/api/src/routes/v2/status-pages.http.ts @@ -4,6 +4,7 @@ import { StatusPageComponentNotFoundError, StatusPageNotFoundError, StatusPagePersistenceError, + StatusPagesDisabledError, type StatusPageComponentRecord, type StatusPageIncidentRecord, type StatusPageRecord, @@ -34,6 +35,10 @@ import { auditDiff, diffAuditChanges } 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 { + OrganizationFeatureFlagsService, + type OrganizationFeatureFlagsServiceApi, +} from "@maple/backend/services/org/OrganizationFeatureFlagsService" import { StatusPagePublisher } from "@maple/backend/services/status-pages/StatusPagePublisher" import { StatusPageSnapshotBuilder, @@ -47,6 +52,19 @@ import { StatusPageStore } from "@maple/backend/services/status-pages/StatusPage const adminOnly = (action: string) => () => V2InsufficientPermissions.make(`Only org admins can ${action} status pages`) +/** The caller's tenant, once its org is in the `statusPages` rollout. */ +const tenantInRollout = (featureFlags: OrganizationFeatureFlagsServiceApi) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const flags = yield* featureFlags.flags(tenant.orgId) + if (!flags.statusPages) { + return yield* new StatusPagesDisabledError({ + message: "Status pages are not enabled for this organization.", + }) + } + return tenant + }) + export const toV2StatusPage = (page: StatusPageRecord): V2StatusPage => ({ id: page.id, object: "status_page", @@ -152,6 +170,7 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag Effect.gen(function* () { const service = yield* StatusPagesService const snapshotBuilder = yield* StatusPageSnapshotBuilder + const enabledTenant = tenantInRollout(yield* OrganizationFeatureFlagsService) /** The component, only when it is on this page; anything else reads as not found. */ const requireComponentOnPage = ( @@ -176,7 +195,7 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag return handlers .handle("list", ({ query }) => Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context + const tenant = yield* enabledTenant const pages = yield* service.list(tenant.orgId) const page = yield* paginateArray(pages.map(toV2StatusPage), query) return { object: "list" as const, ...page } @@ -184,7 +203,7 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag ) .handle("preview", ({ payload }) => Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context + const tenant = yield* enabledTenant const nowMs = yield* Clock.currentTimeMillis return yield* snapshotBuilder .buildDraft(tenant.orgId, toStatusPageDraft(payload), nowMs) @@ -197,13 +216,13 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag ) .handle("retrieve", ({ params }) => Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context + const tenant = yield* enabledTenant return toV2StatusPage(yield* service.get(tenant.orgId, params.id)) }), ) .handle("create", ({ payload }) => Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context + const tenant = yield* enabledTenant yield* requireAdmin(tenant.roles, adminOnly("create")) const created = yield* service.create(tenant.orgId, { name: payload.name, @@ -266,7 +285,7 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag ) .handle("update", ({ params, payload }) => Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context + const tenant = yield* enabledTenant yield* requireAdmin(tenant.roles, adminOnly("update")) const current = yield* service.get(tenant.orgId, params.id) const updated = yield* service.update(tenant.orgId, params.id, { @@ -302,7 +321,7 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag ) .handle("delete", ({ params }) => Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context + const tenant = yield* enabledTenant yield* requireAdmin(tenant.roles, adminOnly("delete")) const current = yield* service.get(tenant.orgId, params.id) const deleted = yield* service.remove(tenant.orgId, params.id) @@ -337,7 +356,7 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag ) .handle("publish", ({ params }) => Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context + const tenant = yield* enabledTenant yield* requireAdmin(tenant.roles, adminOnly("publish")) // A draft goes public first, so the publish below writes it. const page = yield* service.makePublic(tenant.orgId, params.id) @@ -373,7 +392,7 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag ) .handle("rotateBreakGlass", ({ params }) => Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context + const tenant = yield* enabledTenant 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 }) @@ -387,7 +406,7 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag ) .handle("revokeBreakGlass", ({ params }) => Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context + const tenant = yield* enabledTenant 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 }) @@ -396,13 +415,13 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag ) .handle("getConfig", ({ params }) => Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context + const tenant = yield* enabledTenant return yield* service.getConfig(tenant.orgId, params.id) }), ) .handle("applyConfig", ({ params, query, payload }) => Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context + const tenant = yield* enabledTenant yield* requireAdmin(tenant.roles, adminOnly("update")) const { previous, config, changes } = yield* service.applyConfig( tenant.orgId, @@ -430,7 +449,7 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag ) .handle("listComponents", ({ params, query }) => Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context + const tenant = yield* enabledTenant const components = yield* service.listComponents(tenant.orgId, params.id) const page = yield* paginateArray(components.map(toV2StatusPageComponent), query) return { object: "list" as const, ...page } @@ -438,7 +457,7 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag ) .handle("createComponent", ({ params, payload }) => Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context + const tenant = yield* enabledTenant yield* requireAdmin(tenant.roles, adminOnly("edit")) const created = yield* service.createComponent(tenant.orgId, params.id, { name: payload.name, @@ -463,7 +482,7 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag ) .handle("reorderComponents", ({ params, payload }) => Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context + const tenant = yield* enabledTenant yield* requireAdmin(tenant.roles, adminOnly("edit")) const components = yield* service.reorderComponents( tenant.orgId, @@ -481,7 +500,7 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag ) .handle("updateComponent", ({ params, payload }) => Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context + const tenant = yield* enabledTenant yield* requireAdmin(tenant.roles, adminOnly("edit")) const current = yield* requireComponentOnPage( tenant.orgId, @@ -516,7 +535,7 @@ export const HttpV2StatusPagesLive = HttpApiBuilder.group(MapleApiV2, "statusPag ) .handle("deleteComponent", ({ params }) => Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context + const tenant = yield* enabledTenant yield* requireAdmin(tenant.roles, adminOnly("edit")) const current = yield* requireComponentOnPage( tenant.orgId, @@ -540,11 +559,12 @@ export const HttpV2StatusPageIncidentsLive = HttpApiBuilder.group( (handlers) => Effect.gen(function* () { const service = yield* StatusPagesService + const enabledTenant = tenantInRollout(yield* OrganizationFeatureFlagsService) return handlers .handle("list", ({ query }) => Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context + const tenant = yield* enabledTenant const state = query.state ?? "open" const page = yield* paginateOffsetQuery(query, ({ limit, offset }) => service.listOrgIncidents(tenant.orgId, { @@ -560,13 +580,13 @@ export const HttpV2StatusPageIncidentsLive = HttpApiBuilder.group( ) .handle("retrieve", ({ params }) => Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context + const tenant = yield* enabledTenant return toV2StatusPageIncident(yield* service.getIncident(tenant.orgId, params.id)) }), ) .handle("create", ({ payload }) => Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context + const tenant = yield* enabledTenant const kind = payload.kind ?? "incident" const created = yield* service.createIncident(tenant.orgId, payload.page_id, { kind, @@ -601,7 +621,7 @@ export const HttpV2StatusPageIncidentsLive = HttpApiBuilder.group( ) .handle("update", ({ params, payload }) => Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context + const tenant = yield* enabledTenant const current = yield* service.getIncident(tenant.orgId, params.id) const scheduledStart = parseTimestamp(payload.scheduled_start) const scheduledEnd = parseTimestamp(payload.scheduled_end) @@ -631,7 +651,7 @@ export const HttpV2StatusPageIncidentsLive = HttpApiBuilder.group( ) .handle("delete", ({ params }) => Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context + const tenant = yield* enabledTenant const current = yield* service.getIncident(tenant.orgId, params.id) const removed = yield* service.removeIncident(tenant.orgId, params.id) yield* recordHttpAudit("status_page_incident.deleted", { @@ -647,7 +667,7 @@ export const HttpV2StatusPageIncidentsLive = HttpApiBuilder.group( ) .handle("postUpdate", ({ params, payload }) => Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context + const tenant = yield* enabledTenant const updated = yield* service.postUpdate(tenant.orgId, params.id, { status: payload.status, body: payload.body, diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts index 009c8c96ac..81954a0074 100644 --- a/apps/api/src/routes/v2/v2-test-support.ts +++ b/apps/api/src/routes/v2/v2-test-support.ts @@ -163,6 +163,7 @@ const v2GroupLayersExceptOnboardingChecklist = ( chatWorkspace: Layer.Layer, auditLog: Layer.Layer = AuditLogService.layerMemory, uptimeMonitors: Layer.Layer = UptimeMonitorsServiceStubLayer, + featureFlags: Layer.Layer = OrganizationFeatureFlagsService.allEnabled, ) => Layer.mergeAll( HttpV2ApiKeysLive, @@ -188,7 +189,9 @@ const v2GroupLayersExceptOnboardingChecklist = ( HttpV2UptimeMonitorsLive.pipe(Layer.provide(uptimeMonitors)), // Real service: it needs only the Database every harness already provides. Layer.mergeAll(HttpV2StatusPagesLive, HttpV2StatusPageIncidentsLive).pipe( - Layer.provide(Layer.mergeAll(StatusPagesService.layer, StatusPageSnapshotBuilder.layer)), + Layer.provide( + Layer.mergeAll(StatusPagesService.layer, StatusPageSnapshotBuilder.layer, featureFlags), + ), ), HttpV2InstrumentationRecommendationsLive, HttpV2InstrumentationAuditLive, @@ -305,9 +308,10 @@ export const allV2GroupLayersWithChat = ( chatWorkspace: Layer.Layer, auditLog: Layer.Layer = AuditLogService.layerMemory, uptimeMonitors: Layer.Layer = UptimeMonitorsServiceStubLayer, + featureFlags: Layer.Layer = OrganizationFeatureFlagsService.allEnabled, ) => Layer.mergeAll( - v2GroupLayersExceptOnboardingChecklist(chatWorkspace, auditLog, uptimeMonitors), + v2GroupLayersExceptOnboardingChecklist(chatWorkspace, auditLog, uptimeMonitors, featureFlags), HttpV2OnboardingChecklistLive.pipe(Layer.provide(OnboardingChecklistServiceStubLayer)), ).pipe( // Mutation handlers across the groups record audit entries; the real service @@ -325,6 +329,18 @@ export const allV2GroupLayersWithUptime = (uptimeMonitors: Layer.Layer) => allV2GroupLayersWithChat(ChatWorkspaceServiceStubLayer, auditLog) +/** Every v2 group, with the org rollout flags the status page groups read, and optionally a readable audit log. */ +export const allV2GroupLayersWithFeatureFlags = ( + featureFlags: Layer.Layer, + auditLog: Layer.Layer = AuditLogService.layerMemory, +) => + allV2GroupLayersWithChat( + ChatWorkspaceServiceStubLayer, + auditLog, + UptimeMonitorsServiceStubLayer, + featureFlags, + ) + export const ApiV2RateLimiterAllowAllLayer = Layer.succeed(ApiV2RateLimiter, { check: () => Effect.succeed("allowed" as const), }) diff --git a/apps/api/src/runtime/service-graph.ts b/apps/api/src/runtime/service-graph.ts index b6107afcdf..d64c2ba605 100644 --- a/apps/api/src/runtime/service-graph.ts +++ b/apps/api/src/runtime/service-graph.ts @@ -80,6 +80,7 @@ import { SourceRemovalService } from "@maple/backend/services/integrations/Sourc import { SourceUsageService } from "@maple/backend/services/org/SourceUsageService" import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" import { OrganizationService } from "@maple/backend/services/org/OrganizationService" +import { OrganizationFeatureFlagsService } from "@maple/backend/services/org/OrganizationFeatureFlagsService" import { LiveActivitiesService } from "@maple/backend/services/push/LiveActivitiesService" import { MobileDevicesService } from "@maple/backend/services/push/MobileDevicesService" import { AgentFeedbackService } from "@maple/backend/services/feedback/AgentFeedbackService" @@ -133,6 +134,8 @@ export const HttpServicesLive = Layer.mergeAll( ScrapeTargetsService.layer, UptimeMonitorsService.layer, StatusPagesService.layer, + // The `statusPages` rollout gate on the status page routes. + OrganizationFeatureFlagsService.layer, // Read-only: previews of unsaved pages. StatusPageSnapshotBuilder.layer, // Needs the `StatusPageStore` port, which only the deployed Worker binds. diff --git a/apps/web/src/page/onboarding/create-org.scene.test.ts b/apps/web/src/page/onboarding/create-org.scene.test.ts index afbc6b43a1..24e2adc56c 100644 --- a/apps/web/src/page/onboarding/create-org.scene.test.ts +++ b/apps/web/src/page/onboarding/create-org.scene.test.ts @@ -37,7 +37,7 @@ const member: ClerkOrganization = { homeRegion: "us", isServedHere: true, isRegionOpen: false, - flags: { aiAutoTriage: false, releases: false, securityAudit: false }, + flags: { aiAutoTriage: false, releases: false, securityAudit: false, statusPages: false }, } const view = (model: Model, h: HtmlBuilder): Html => createOrgView(h, model, "david@acme.dev", []) diff --git a/apps/web/src/page/onboarding/plan-gate.test.ts b/apps/web/src/page/onboarding/plan-gate.test.ts index 7323241312..5ce999dfc5 100644 --- a/apps/web/src/page/onboarding/plan-gate.test.ts +++ b/apps/web/src/page/onboarding/plan-gate.test.ts @@ -13,7 +13,7 @@ const organization: ClerkOrganization = { homeRegion: "us", isServedHere: true, isRegionOpen: false, - flags: { aiAutoTriage: false, releases: false, securityAudit: false }, + flags: { aiAutoTriage: false, releases: false, securityAudit: false, statusPages: false }, } const customerWith = (status: string | null): CustomerSlot => ({ diff --git a/apps/web/src/page/onboarding/wrong-region.scene.test.ts b/apps/web/src/page/onboarding/wrong-region.scene.test.ts index 6f865d4908..f985ee4554 100644 --- a/apps/web/src/page/onboarding/wrong-region.scene.test.ts +++ b/apps/web/src/page/onboarding/wrong-region.scene.test.ts @@ -15,7 +15,7 @@ const organization: ClerkOrganization = { homeRegion: "eu", isServedHere: false, isRegionOpen: false, - flags: { aiAutoTriage: false, releases: false, securityAudit: false }, + flags: { aiAutoTriage: false, releases: false, securityAudit: false, statusPages: false }, } const usOrganization: ClerkOrganization = { diff --git a/apps/web/src/page/status-pages/editor/editor.scene.test.ts b/apps/web/src/page/status-pages/editor/editor.scene.test.ts index 86f3e9018a..800f8d9e20 100644 --- a/apps/web/src/page/status-pages/editor/editor.scene.test.ts +++ b/apps/web/src/page/status-pages/editor/editor.scene.test.ts @@ -396,6 +396,19 @@ describe("the page as the editor", () => { ]) }) + test("a failed add closes the panel, so a retry cannot add the components that did land twice", () => { + const adding = pageUpdate(loaded(), Message.ClickedAddComponent()).model + const failed = pageUpdate( + { ...adding, isAddingComponents: true }, + Message.CompletedAddComponents({ + result: Result.fail(toLoadFailure(new Error("second create refused"))), + }), + ) + expect(failed.model.panel).toBeNull() + expect(failed.model.isAddingComponents).toBe(false) + expect(failed.outMessage).toMatchObject({ _tag: "RequestedToast", type: "error" }) + }) + test("members see the page and its components without any control to change them", () => { scene( { update: pageUpdate, view }, @@ -611,6 +624,9 @@ describe("live screen", () => { }), expectView(role("heading", { name: "Acme Status is not public yet" })).toExist(), expectView(role("heading", { name: "Acme Status is live" })).not.toExist(), + // The publisher skips a draft, so its address would not resolve: no link to open. + expectView(role("link", { name: "Open" })).not.toExist(), + expectView(role("link", { name: "https://status-pr-7.maple.dev/s/acme-status" })).not.toExist(), ) }) diff --git a/apps/web/src/page/status-pages/editor/model.ts b/apps/web/src/page/status-pages/editor/model.ts index 5243ffc16e..f987062fb4 100644 --- a/apps/web/src/page/status-pages/editor/model.ts +++ b/apps/web/src/page/status-pages/editor/model.ts @@ -71,7 +71,6 @@ export const Model = Schema.Struct({ isReordering: Schema.Boolean, addPicker: Picker, isAddingComponents: Schema.Boolean, - addFailure: Schema.NullOr(LoadFailure), // Incidents incidentForm: IncidentForm, isCreatingIncident: Schema.Boolean, diff --git a/apps/web/src/page/status-pages/editor/update-components.ts b/apps/web/src/page/status-pages/editor/update-components.ts index 1077799356..23b3e09375 100644 --- a/apps/web/src/page/status-pages/editor/update-components.ts +++ b/apps/web/src/page/status-pages/editor/update-components.ts @@ -241,7 +241,7 @@ export const openAdd = (model: Model): Return => { monitorsOf(model).filter((monitor) => !used.has(monitor.id)), false, ) - return focusPanel({ ...model, panel: { _tag: "Add" }, addPicker: picker, addFailure: null }) + return focusPanel({ ...model, panel: { _tag: "Add" }, addPicker: picker }) } export const addPickerStep = (model: Model, message: PickerMessage): Return => { @@ -285,7 +285,7 @@ export const submitAdd = (model: Model): Return => { hasRowIssues(model.addPicker) ? { model } : { - model: { ...model, isAddingComponents: true, addFailure: null }, + model: { ...model, isAddingComponents: true }, commands: [AddComponents({ pageId: model.pageId, components })], } } @@ -305,7 +305,11 @@ export const addedComponents = ( added.map((component) => component.name).join(", "), ), }), - // Some may have been created before the failure: the re-read shows which. - onFailure: (addFailure): Return => reread({ ...settled, addFailure }, context), + // Some may have been created before the failure, so the panel closes rather than offer a + // retry that would add them twice; the re-read lists what landed. + onFailure: (addFailure): Return => ({ + ...reread({ ...settled, panel: null }, context), + outMessage: errorToast("Couldn't add every component", addFailure), + }), }) } diff --git a/apps/web/src/page/status-pages/editor/update.ts b/apps/web/src/page/status-pages/editor/update.ts index c751d9842b..0a2963514f 100644 --- a/apps/web/src/page/status-pages/editor/update.ts +++ b/apps/web/src/page/status-pages/editor/update.ts @@ -134,7 +134,6 @@ export const init = (route: EditorRoute, context: PageContext): Return => { isReordering: false, addPicker: emptyPicker, isAddingComponents: false, - addFailure: null, incidentForm: emptyIncidentForm("incident"), isCreatingIncident: false, incidentFailure: null, diff --git a/apps/web/src/page/status-pages/editor/view-live.ts b/apps/web/src/page/status-pages/editor/view-live.ts index 9640cbcfbf..28fbcc484d 100644 --- a/apps/web/src/page/status-pages/editor/view-live.ts +++ b/apps/web/src/page/status-pages/editor/view-live.ts @@ -21,19 +21,20 @@ export const NO_DOMAIN_NOTE = "Public address appears once a status domain is co /** The header's address: a link to the public page, or the note while there is no status domain. */ export const addressLine = (h: H, model: Model, page: V2StatusPage): Html => { const url = publicUrl(model.publicBaseUrl, page.slug) - return url === null - ? h.span([h.Class("text-xs text-muted-foreground")], [NO_DOMAIN_NOTE]) - : h.a( - [ - h.Href(url), - h.Target("_blank"), - h.Rel("noopener"), - h.Class( - "font-mono text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline", - ), - ], - [url], - ) + if (url === null) return h.span([h.Class("text-xs text-muted-foreground")], [NO_DOMAIN_NOTE]) + // A draft is never published, so its address does not resolve yet: text, not a link. + if (page.draft) return h.span([h.Class("font-mono text-xs text-muted-foreground")], [url]) + return h.a( + [ + h.Href(url), + h.Target("_blank"), + h.Rel("noopener"), + h.Class( + "font-mono text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline", + ), + ], + [url], + ) } const nextStep = ( @@ -123,16 +124,20 @@ export const liveView = (h: H, model: Model, page: V2StatusPage): Html => { }), ], ), - linkButton( - h, - { - size: "sm", - variant: "outline", - href: url, - attributes: [h.Target("_blank"), h.Rel("noopener")], - }, - [icon(h, "ExternalLinkIcon"), "Open"], - ), + ...(page.draft + ? [] + : [ + linkButton( + h, + { + size: "sm", + variant: "outline", + href: url, + attributes: [h.Target("_blank"), h.Rel("noopener")], + }, + [icon(h, "ExternalLinkIcon"), "Open"], + ), + ]), ], ), h.section( diff --git a/apps/web/src/page/status-pages/editor/view-panel.ts b/apps/web/src/page/status-pages/editor/view-panel.ts index 0c044cc862..914417705c 100644 --- a/apps/web/src/page/status-pages/editor/view-panel.ts +++ b/apps/web/src/page/status-pages/editor/view-panel.ts @@ -658,7 +658,6 @@ export const addPanel = (h: H, model: Model): Html => { focusOnMount: h.OnMount(FocusNewRow()), isOrderable: false, }), - ...failureAlert(h, "Couldn't add every component", model.addFailure), h.div( [h.Class("flex justify-end border-t pt-4")], [ diff --git a/apps/web/src/page/status-pages/shared/commands.test.ts b/apps/web/src/page/status-pages/shared/commands.test.ts new file mode 100644 index 0000000000..e381928fe5 --- /dev/null +++ b/apps/web/src/page/status-pages/shared/commands.test.ts @@ -0,0 +1,43 @@ +import { describe, it } from "@effect/vitest" +import { MapleApiV2, V2StatusPageComponent } from "@maple/domain/http/v2" +import { Effect, Layer, Option, Ref, Schema } from "effect" +import { HttpClient, HttpClientResponse, UrlParams } from "effect/http" +import { HttpApiClient } from "effect/http-api" +import { expect } from "vitest" +import { MapleApiV2Client } from "../../../platform/api-clients" +import { listComponents } from "./commands" +import { component, componentId, pageId } from "./test-pages" + +const encode = Schema.encodeSync(V2StatusPageComponent) + +describe("listComponents", () => { + it.effect("follows next_cursor until the last page", () => + Effect.gen(function* () { + const cursors = yield* Ref.make>([]) + const pages = [ + { data: [encode(component(1))], has_more: true, next_cursor: "page-2" }, + { data: [encode(component(2))], has_more: false, next_cursor: null }, + ] + const http = HttpClient.make((request) => + Ref.modify(cursors, (seen) => [ + seen.length, + [...seen, Option.getOrNull(UrlParams.getFirst(request.urlParams, "cursor"))], + ]).pipe( + Effect.map((index) => + HttpClientResponse.fromWeb( + request, + Response.json({ object: "list", ...pages[Math.min(index, pages.length - 1)] }), + ), + ), + ), + ) + const client = Layer.effect( + MapleApiV2Client, + HttpApiClient.make(MapleApiV2, { baseUrl: "https://api.test" }), + ).pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, http))) + const components = yield* listComponents(pageId(1)).pipe(Effect.provide(client)) + expect(components.map((row) => row.id)).toEqual([componentId(1), componentId(2)]) + expect(yield* Ref.get(cursors)).toEqual([null, "page-2"]) + }), + ) +}) diff --git a/apps/web/src/page/status-pages/shared/commands.ts b/apps/web/src/page/status-pages/shared/commands.ts index 7235bca888..1a28562bb8 100644 --- a/apps/web/src/page/status-pages/shared/commands.ts +++ b/apps/web/src/page/status-pages/shared/commands.ts @@ -19,26 +19,51 @@ import { summarizePage } from "./derive" // The status page reads and writes, as Effects the pages' Commands wrap, and their links. -/** One page of each list: far past any plan's allowance of pages, components or open incidents. */ const LIST_LIMIT = 100 +/** Pages read at most per list: 2000 rows, far past any plan's allowance. */ +const MAX_LIST_PAGES = 20 /** Recent incidents the editor lists: the open ones plus the latest closed. */ const INCIDENT_LIMIT = 50 +interface ListPage { + readonly data: ReadonlyArray + readonly has_more: boolean + readonly next_cursor: string | null +} + +/** Every row of a cursor-paginated list: a summary of a truncated read would hide a failing row. */ +const readAll = ( + read: (cursor: string | undefined) => Effect.Effect, E, R>, + cursor?: string, + pages = 1, +): Effect.Effect, E, R> => + Effect.flatMap(read(cursor), (response) => + response.has_more && response.next_cursor !== null && pages < MAX_LIST_PAGES + ? Effect.map(readAll(read, response.next_cursor, pages + 1), (rest) => [ + ...response.data, + ...rest, + ]) + : Effect.succeed(response.data), + ) + const listPages = Effect.gen(function* () { const client = yield* MapleApiV2Client - return (yield* client.statusPages.list({ query: { limit: LIST_LIMIT } })).data + return yield* readAll((cursor) => client.statusPages.list({ query: { limit: LIST_LIMIT, cursor } })) }) const listPageComponents = (id: StatusPageId) => Effect.gen(function* () { const client = yield* MapleApiV2Client - return (yield* client.statusPages.listComponents({ params: { id }, query: { limit: LIST_LIMIT } })) - .data + return yield* readAll((cursor) => + client.statusPages.listComponents({ params: { id }, query: { limit: LIST_LIMIT, cursor } }), + ) }) const listOpenIncidents = Effect.gen(function* () { const client = yield* MapleApiV2Client - return (yield* client.statusPageIncidents.list({ query: { state: "open", limit: LIST_LIMIT } })).data + return yield* readAll((cursor) => + client.statusPageIncidents.list({ query: { state: "open", limit: LIST_LIMIT, cursor } }), + ) }) /** The list's rows: each page with its components' worst status and its open incident count. */ diff --git a/apps/web/src/platform/clerk.ts b/apps/web/src/platform/clerk.ts index a726f1c49a..3ea4ba8b1b 100644 --- a/apps/web/src/platform/clerk.ts +++ b/apps/web/src/platform/clerk.ts @@ -215,6 +215,7 @@ export const ClerkOrganization = Schema.Struct({ aiAutoTriage: Schema.Boolean, releases: Schema.Boolean, securityAudit: Schema.Boolean, + statusPages: Schema.Boolean, }), }) export type ClerkOrganization = typeof ClerkOrganization.Type diff --git a/apps/web/src/route/index.ts b/apps/web/src/route/index.ts index 14ad2b74c7..952e2e784a 100644 --- a/apps/web/src/route/index.ts +++ b/apps/web/src/route/index.ts @@ -1,3 +1,3 @@ export { AppRoute, isPublicRoute, type RouteTag } from "./app-route" -export { Redirect, routeRedirect } from "./redirect" +export { flaggedRouteRedirect, Redirect, routeRedirect } from "./redirect" export { hrefOf, urlToAppRoute } from "./routers" diff --git a/apps/web/src/route/redirect.ts b/apps/web/src/route/redirect.ts index d85a429f6e..912359d87f 100644 --- a/apps/web/src/route/redirect.ts +++ b/apps/web/src/route/redirect.ts @@ -1,3 +1,4 @@ +import type { OrganizationFeatureFlags } from "@maple/domain/organization-feature-flags" import { Option, Schema } from "effect" import { AppRoute } from "./app-route" import { hrefOf } from "./routers" @@ -85,3 +86,14 @@ export const routeRedirect = (route: AppRoute): Option.Option => }, () => Option.none(), ) + +const isStatusPageRoute = AppRoute.isAnyOf(["StatusPages", "StatusPageNew", "StatusPage"]) + +/** A route an org outside its rollout cannot open goes home (`/status-pages` without `statusPages`). */ +export const flaggedRouteRedirect = ( + route: AppRoute, + flags: Pick, +): Option.Option => + !flags.statusPages && isStatusPageRoute(route) + ? redirect(AppRoute.Home({ search: {} }), true) + : Option.none() diff --git a/apps/web/src/route/route.test.ts b/apps/web/src/route/route.test.ts index 8958bee397..5b7f8dbc37 100644 --- a/apps/web/src/route/route.test.ts +++ b/apps/web/src/route/route.test.ts @@ -2,7 +2,7 @@ import { Option } from "effect" import { fromString } from "foldkit/url" import { describe, expect, test } from "vitest" import { DATA_INTEGRATIONS } from "./data-integrations" -import { AppRoute, hrefOf, routeRedirect, urlToAppRoute } from "./index" +import { AppRoute, flaggedRouteRedirect, hrefOf, routeRedirect, urlToAppRoute } from "./index" const parse = (path: string) => urlToAppRoute(Option.getOrThrow(fromString(`http://maple.test${path}`))) @@ -64,6 +64,16 @@ describe("routes", () => { ) }) + test("an org outside the statusPages rollout is sent home from every status page route", () => { + for (const path of ["/status-pages", "/status-pages/new", "/status-pages/stp_abc"]) { + expect(flaggedRouteRedirect(parse(path), { statusPages: false })).toEqual( + Option.some({ url: "/", isReplace: true }), + ) + expect(flaggedRouteRedirect(parse(path), { statusPages: true })).toEqual(Option.none()) + } + expect(flaggedRouteRedirect(parse("/services"), { statusPages: false })).toEqual(Option.none()) + }) + test("a literal sibling wins over a param route", () => { expect(parse("/infra/hosts")._tag).toBe("InfraHosts") expect(parse("/alerts/create")._tag).toBe("AlertsCreate") diff --git a/apps/web/src/shell/nav.test.ts b/apps/web/src/shell/nav.test.ts index 93a9f4fff4..c8b7284dcc 100644 --- a/apps/web/src/shell/nav.test.ts +++ b/apps/web/src/shell/nav.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest" -import { isNavItemActive, navGroups, navPreview, partitionSubItems } from "./nav" +import { isNavItemActive, type NavFlags, navGroups, navPreview, partitionSubItems } from "./nav" -const infrastructure = navGroups({ releases: false, securityAudit: false }) +const infrastructure = navGroups({ releases: false, securityAudit: false, statusPages: false }) .flatMap((group) => group.items) .find((item) => item.title === "Infrastructure") const subItems = infrastructure?.subItems ?? [] @@ -28,22 +28,36 @@ describe("navPreview", () => { }) describe("isNavItemActive", () => { - const activeTitles = (path: string, flags: { releases: boolean; securityAudit: boolean }) => + const activeTitles = (path: string, flags: NavFlags) => navGroups(flags) .flatMap((group) => group.items) .filter((item) => isNavItemActive(path, item)) .map((item) => item.title) test("pages without a row of their own highlight their parent row", () => { - const off = { releases: false, securityAudit: false } + const off = { releases: false, securityAudit: false, statusPages: false } expect(activeTitles("/anomalies", off)).toEqual(["Alerts"]) expect(activeTitles("/security/findings", off)).toEqual(["Code Review"]) expect(activeTitles("/releases", off)).toEqual(["Services"]) + expect(activeTitles("/status-pages/stp_abc", off)).toEqual(["Services"]) }) test("a flagged page with its own row highlights only that row", () => { - const on = { releases: true, securityAudit: true } + const on = { releases: true, securityAudit: true, statusPages: true } expect(activeTitles("/security", on)).toEqual(["Security"]) expect(activeTitles("/releases", on)).toEqual(["Releases"]) + expect(activeTitles("/status-pages/stp_abc", on)).toEqual(["Status pages"]) + }) +}) + +describe("navGroups", () => { + const titles = (flags: NavFlags) => + navGroups(flags).flatMap((group) => group.items.map((item) => item.title)) + + test("the Status pages row shows only for an org in the statusPages rollout", () => { + expect(titles({ releases: false, securityAudit: false, statusPages: false })).not.toContain( + "Status pages", + ) + expect(titles({ releases: false, securityAudit: false, statusPages: true })).toContain("Status pages") }) }) diff --git a/apps/web/src/shell/nav.ts b/apps/web/src/shell/nav.ts index 479eee0faf..740f4cfe25 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -128,17 +128,26 @@ const exploreItem: NavItem = { ], } -/** `navGroups(flags)`; `releases` and `securityAudit` gate a row each. */ -export const navGroups = ( - flags: Readonly<{ releases: boolean; securityAudit: boolean }>, -): ReadonlyArray => [ +/** The org rollout flags that gate a nav row each. */ +export type NavFlags = Readonly<{ releases: boolean; securityAudit: boolean; statusPages: boolean }> + +/** `navGroups(flags)`; `releases`, `securityAudit` and `statusPages` gate a row each. */ +export const navGroups = (flags: NavFlags): ReadonlyArray => [ { id: "overview", items: [{ title: "Overview", href: "/", icon: "HouseIcon" }] }, { id: "monitor", label: "Monitor", items: [ - { ...servicesItem, alsoActive: flags.releases ? [] : ["/releases"] }, - { title: "Status pages", href: "/status-pages", icon: "WindowChartLineIcon" }, + { + ...servicesItem, + alsoActive: [ + ...(flags.releases ? [] : ["/releases"]), + ...(flags.statusPages ? [] : ["/status-pages"]), + ], + }, + ...(flags.statusPages + ? [{ title: "Status pages", href: "/status-pages", icon: "WindowChartLineIcon" } as const] + : []), ...(flags.releases ? [{ title: "Releases", href: "/releases", icon: "RocketIcon" } as const] : []), diff --git a/apps/web/src/shell/palette/entries.ts b/apps/web/src/shell/palette/entries.ts index 5fa9cd8f22..72ddd4d522 100644 --- a/apps/web/src/shell/palette/entries.ts +++ b/apps/web/src/shell/palette/entries.ts @@ -53,14 +53,14 @@ export const SearchEntry = Schema.Struct({ id: Schema.String, title: Schema.Stri export type SearchEntry = typeof SearchEntry.Type /** Nav rows an org rollout flag can hide; the shell passes the ones it shows. */ -export const FLAGGED_HREFS: ReadonlySet = new Set(["/releases", "/security"]) +export const FLAGGED_HREFS: ReadonlySet = new Set(["/releases", "/security", "/status-pages"]) const icon = (name: IconName): Glyph => ({ _tag: "Icon", name }) const href = (url: string): Target => ({ _tag: "Href", href: url }) /** `paletteNavItems`: every section, its children and their views, deduplicated by title and href. */ const navigationEntries = (): ReadonlyArray => { - const rows = navGroups({ releases: true, securityAudit: true }).flatMap((group) => + const rows = navGroups({ releases: true, securityAudit: true, statusPages: true }).flatMap((group) => group.items.flatMap((item) => [ { id: `nav:${item.title}`, title: item.title, href: item.href, icon: item.icon }, ...(item.subItems ?? []).flatMap((sub) => [ diff --git a/apps/web/src/shell/view-nav.ts b/apps/web/src/shell/view-nav.ts index 95391cd8b5..e1d7e1d039 100644 --- a/apps/web/src/shell/view-nav.ts +++ b/apps/web/src/shell/view-nav.ts @@ -23,6 +23,7 @@ import { isNavItemActive, matchSubItem, type NavGroup, + type NavFlags, type NavItem, type NavSubItem, type NavSurface, @@ -37,10 +38,14 @@ import { ACTIVE_RAIL, GROUP_LABEL, NO_TIP, navBadge, type RailTip, triggerFor } * metadata, and fails closed (no flagged rows) until Clerk has an org. */ export const shellNavGroups = (clerk: ShellClerk | null = null): ReadonlyArray => { - const flag = (name: "releases" | "securityAudit") => + const flag = (name: keyof NavFlags) => !isClerkAuthEnabled || (clerk !== null && Option.exists(clerk.maybeOrganization, (organization) => organization.flags[name])) - return navGroups({ releases: flag("releases"), securityAudit: flag("securityAudit") }) + return navGroups({ + releases: flag("releases"), + securityAudit: flag("securityAudit"), + statusPages: flag("statusPages"), + }) } /** The last-hour probe's answer; `null` while it is first in flight or after it failed. */ @@ -180,7 +185,12 @@ const navRow = ( ], ), ...(preview.overflow > 0 - ? [h.span([h.Class("ms-0.5 text-2xs tabular-nums")], [`+${preview.overflow}`])] + ? [ + h.span( + [h.Class("ms-0.5 text-2xs tabular-nums")], + [`+${preview.overflow}`], + ), + ] : []), ], ), diff --git a/apps/web/src/update.customer-grace.test.ts b/apps/web/src/update.customer-grace.test.ts index 1cd15079e2..bf528e5af9 100644 --- a/apps/web/src/update.customer-grace.test.ts +++ b/apps/web/src/update.customer-grace.test.ts @@ -53,7 +53,7 @@ const snapshot: ClerkSnapshot = { homeRegion: "us", isServedHere: true, isRegionOpen: false, - flags: { aiAutoTriage: false, releases: false, securityAudit: false }, + flags: { aiAutoTriage: false, releases: false, securityAudit: false, statusPages: false }, }), memberships: [], maybePendingTask: Option.none(), @@ -99,3 +99,27 @@ describe("plan gate splash on a slow customer read", () => { expect(Option.map(redirect, ({ url }) => url.startsWith("/quick-start"))).toEqual(Option.some(true)) }) }) + +describe("statusPages rollout", () => { + const withFlag = (statusPages: boolean): ClerkSnapshot => ({ + ...snapshot, + maybeOrganization: Option.map(snapshot.maybeOrganization, (organization) => ({ + ...organization, + flags: { ...organization.flags, statusPages }, + })), + }) + const atStatusPages = (): Model => ({ ...base({ [orgId]: true }), route: AppRoute.StatusPages() }) + + test("an org outside the rollout is sent home without the status pages list mounting", () => { + const loaded = update(atStatusPages(), Message.CompletedLoadClerk({ snapshot: withFlag(false) })) + expect(loaded.model.page).toBeNull() + expect(namesOf(loaded)).toContain("ReplaceUrl") + expect(namesOf(loaded)).not.toContain("FetchStatusPages") + }) + + test("an org in the rollout gets the list", () => { + const loaded = update(atStatusPages(), Message.CompletedLoadClerk({ snapshot: withFlag(true) })) + expect(loaded.model.page).not.toBeNull() + expect(namesOf(loaded)).not.toContain("ReplaceUrl") + }) +}) diff --git a/apps/web/src/update.test.ts b/apps/web/src/update.test.ts index 28ec070f08..2fe4b20963 100644 --- a/apps/web/src/update.test.ts +++ b/apps/web/src/update.test.ts @@ -154,7 +154,7 @@ describe("re-render on change only", () => { homeRegion: "us", isServedHere: true, isRegionOpen: false, - flags: { aiAutoTriage: false, releases: false, securityAudit: false }, + flags: { aiAutoTriage: false, releases: false, securityAudit: false, statusPages: false }, }), memberships: [], maybePendingTask: Option.none(), diff --git a/apps/web/src/update.ts b/apps/web/src/update.ts index f97c3bda4c..b66a1f2bc2 100644 --- a/apps/web/src/update.ts +++ b/apps/web/src/update.ts @@ -61,7 +61,15 @@ import * as SignIn from "./page/sign-in" import type { Resources } from "./platform/resources" import { ClerkSnapshot } from "./platform/clerk" import { identityOf, isSameIdentity } from "./platform/session-identity" -import { AppRoute, hrefOf, isPublicRoute, type Redirect, routeRedirect, urlToAppRoute } from "./route" +import { + AppRoute, + flaggedRouteRedirect, + hrefOf, + isPublicRoute, + type Redirect, + routeRedirect, + urlToAppRoute, +} from "./route" import { completeRangeOf, isWithin, withSessionRange } from "./route/session-time-range" import * as Shell from "./shell" import * as OrgSwitcher from "./shell/clerk-org-switcher" @@ -394,7 +402,11 @@ const isPlanGated = (model: Model) => Option.exists(planGateOf(model), isRouteHe const loadPage: Step = (model) => Option.match( - Option.filter(pageContextOf(model), () => !isPlanGated(model)), + // A flag-gated route is on its way home: no page, so nothing it would fetch. + Option.filter( + pageContextOf(model), + () => !isPlanGated(model) && Option.isNone(flagRedirectOf(model)), + ), { // Leaving every page (signed out) releases what the last one held, like any page change. // Parked pages go too: none may come back in the next session or org. @@ -685,6 +697,14 @@ export const planGateOf = (model: Model): Option.Option => const isWrongRegion = (model: Model): boolean => Option.exists(planGateOf(model), (gate) => gate.wrongRegion !== null) +/** A rollout-gated route the active Clerk org is outside of; self-hosted has every rollout. */ +const flagRedirectOf = (model: Model): Option.Option => + isClerkAuthEnabled + ? Option.flatMap(clerkOrganizationOf(model), (organization) => + flaggedRouteRedirect(model.route, organization.flags), + ) + : Option.none() + const followRedirects: Step = (model) => { const maybeRedirect = Session.match(model.session, { Resolving: () => Option.none(), @@ -692,6 +712,7 @@ const followRedirects: Step = (model) => { SignedIn: () => Option.orElse(routeRedirect(model.route), () => sessionRedirect(model)).pipe( Option.orElse(() => Option.flatMap(planGateOf(model), (gate) => gate.redirect)), + Option.orElse(() => flagRedirectOf(model)), ), }) return Option.match(maybeRedirect, { diff --git a/packages/backend/src/services/audit/audit-access.ts b/packages/backend/src/services/audit/audit-access.ts index 481c66f2ca..f2738ff2f6 100644 --- a/packages/backend/src/services/audit/audit-access.ts +++ b/packages/backend/src/services/audit/audit-access.ts @@ -53,7 +53,7 @@ const clip = (value: string): string => * question an auditor asks of such an entry — which person, in which workspace * — and it is forensic context, never an identity Maple resolves anything by. */ -const originMetadata = (origin: ChatTurnOrigin | undefined) => +export const originMetadata = (origin: ChatTurnOrigin | undefined) => origin?.kind === "connector" ? { connector: clip(origin.connectorId), diff --git a/packages/domain/src/generated/anticipated-error-identifiers.ts b/packages/domain/src/generated/anticipated-error-identifiers.ts index 375840b39d..6795e6a5c5 100644 --- a/packages/domain/src/generated/anticipated-error-identifiers.ts +++ b/packages/domain/src/generated/anticipated-error-identifiers.ts @@ -112,6 +112,7 @@ export const ANTICIPATED_ERROR_IDENTIFIER_LIST: ReadonlyArray = [ "@maple/http/errors/StatusPageNotFoundError", "@maple/http/errors/StatusPageSlugTakenError", "@maple/http/errors/StatusPageValidationError", + "@maple/http/errors/StatusPagesDisabledError", "@maple/http/errors/UnauthorizedError", "@maple/http/errors/UnknownVcsProviderError", "@maple/http/errors/UptimeMonitorLimitReachedError", diff --git a/packages/domain/src/http/status-pages.ts b/packages/domain/src/http/status-pages.ts index bb629e9ca8..9e089677fa 100644 --- a/packages/domain/src/http/status-pages.ts +++ b/packages/domain/src/http/status-pages.ts @@ -141,6 +141,20 @@ export class StatusPagePersistenceError extends HttpTaggedError()( + "@maple/http/errors/StatusPagesDisabledError", + { message: Schema.String }, + { + status: 403, + code: "status_pages_disabled", + title: "Status pages are not enabled", + retry: "never", + recovery: "contact_support", + exposure: "public_message", + }, +) {} + /** A page as its owners see it; the break-glass hash never leaves the service. */ export const StatusPageRecord = Schema.Struct({ id: StatusPageId, diff --git a/packages/domain/src/http/v2/status-pages.ts b/packages/domain/src/http/v2/status-pages.ts index 17608ffecf..764c1e02ec 100644 --- a/packages/domain/src/http/v2/status-pages.ts +++ b/packages/domain/src/http/v2/status-pages.ts @@ -26,6 +26,7 @@ import { StatusPageIncidentNotFoundError, StatusPageNotFoundError, StatusPagePersistenceError, + StatusPagesDisabledError, StatusPageSlugTakenError, StatusPageValidationError, } from "../status-pages" @@ -918,16 +919,25 @@ const IncidentsQuery = Schema.Struct({ description: "Pagination plus optional page and state filters.", }) -const [pageNotFound, componentNotFound, incidentNotFound, validation, slugTaken, conflict, persistence] = - publicErrors( - StatusPageNotFoundError, - StatusPageComponentNotFoundError, - StatusPageIncidentNotFoundError, - StatusPageValidationError, - StatusPageSlugTakenError, - StatusPageConflictError, - StatusPagePersistenceError, - ) +const [ + pageNotFound, + componentNotFound, + incidentNotFound, + validation, + slugTaken, + conflict, + persistence, + disabled, +] = publicErrors( + StatusPageNotFoundError, + StatusPageComponentNotFoundError, + StatusPageIncidentNotFoundError, + StatusPageValidationError, + StatusPageSlugTakenError, + StatusPageConflictError, + StatusPagePersistenceError, + StatusPagesDisabledError, +) const StatusPageList = ListOf(V2StatusPage).annotate({ identifier: "StatusPageList", @@ -963,7 +973,7 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") HttpApiEndpoint.get("list", "/", { query: ListQuery, success: StatusPageList, - error: [V2ParameterInvalid.schema, persistence], + error: [V2ParameterInvalid.schema, persistence, disabled], }).annotateMerge( OpenApi.annotations({ identifier: "listStatusPages", @@ -977,7 +987,7 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") HttpApiEndpoint.post("create", "/", { payload: V2StatusPageCreateParams, success: V2StatusPage, - error: [V2InsufficientPermissions.schema, validation, slugTaken, persistence], + error: [V2InsufficientPermissions.schema, validation, slugTaken, persistence, disabled], }).annotateMerge( OpenApi.annotations({ identifier: "createStatusPage", @@ -990,7 +1000,7 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") HttpApiEndpoint.post("preview", "/preview", { payload: StatusPagePreviewParams, success: StatusPageSnapshot, - error: [V2ParameterInvalid.schema, validation, persistence], + error: [V2ParameterInvalid.schema, validation, persistence, disabled], }).annotateMerge( OpenApi.annotations({ identifier: "previewStatusPage", @@ -1004,7 +1014,7 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") HttpApiEndpoint.get("retrieve", "/:id", { params: pageParams, success: V2StatusPage, - error: [pageNotFound, persistence], + error: [pageNotFound, persistence, disabled], }).annotateMerge( OpenApi.annotations({ identifier: "getStatusPage", @@ -1019,7 +1029,14 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") params: pageParams, payload: V2StatusPageUpdateParams, success: V2StatusPage, - error: [V2InsufficientPermissions.schema, pageNotFound, validation, slugTaken, persistence], + error: [ + V2InsufficientPermissions.schema, + pageNotFound, + validation, + slugTaken, + persistence, + disabled, + ], }).annotateMerge( OpenApi.annotations({ identifier: "updateStatusPage", @@ -1032,7 +1049,7 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") HttpApiEndpoint.delete("delete", "/:id", { params: pageParams, success: V2StatusPageDeleteResponse, - error: [V2InsufficientPermissions.schema, pageNotFound, persistence], + error: [V2InsufficientPermissions.schema, pageNotFound, persistence, disabled], }).annotateMerge( OpenApi.annotations({ identifier: "deleteStatusPage", @@ -1045,7 +1062,7 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") HttpApiEndpoint.post("publish", "/:id/publish", { params: pageParams, success: V2StatusPagePublishResult, - error: [V2InsufficientPermissions.schema, pageNotFound, persistence], + error: [V2InsufficientPermissions.schema, pageNotFound, persistence, disabled], }).annotateMerge( OpenApi.annotations({ identifier: "publishStatusPage", @@ -1058,7 +1075,7 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") HttpApiEndpoint.post("rotateBreakGlass", "/:id/rotate_break_glass", { params: pageParams, success: V2StatusPageBreakGlassToken, - error: [V2InsufficientPermissions.schema, pageNotFound, persistence], + error: [V2InsufficientPermissions.schema, pageNotFound, persistence, disabled], }).annotateMerge( OpenApi.annotations({ identifier: "rotateStatusPageBreakGlassToken", @@ -1071,7 +1088,7 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") HttpApiEndpoint.delete("revokeBreakGlass", "/:id/break_glass", { params: pageParams, success: V2StatusPage, - error: [V2InsufficientPermissions.schema, pageNotFound, persistence], + error: [V2InsufficientPermissions.schema, pageNotFound, persistence, disabled], }).annotateMerge( OpenApi.annotations({ identifier: "revokeStatusPageBreakGlassToken", @@ -1084,7 +1101,7 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") HttpApiEndpoint.get("getConfig", "/:id/config", { params: pageParams, success: StatusPageConfig, - error: [pageNotFound, persistence], + error: [pageNotFound, persistence, disabled], }).annotateMerge( OpenApi.annotations({ identifier: "getStatusPageConfig", @@ -1108,6 +1125,7 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") slugTaken, conflict, persistence, + disabled, ], }) // A misspelt key would otherwise be dropped and its field reset to the default. @@ -1125,7 +1143,7 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") params: pageParams, query: ListQuery, success: StatusPageComponentList, - error: [V2ParameterInvalid.schema, pageNotFound, persistence], + error: [V2ParameterInvalid.schema, pageNotFound, persistence, disabled], }).annotateMerge( OpenApi.annotations({ identifier: "listStatusPageComponents", @@ -1140,7 +1158,7 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") params: pageParams, payload: V2StatusPageComponentCreateParams, success: V2StatusPageComponent, - error: [V2InsufficientPermissions.schema, pageNotFound, validation, persistence], + error: [V2InsufficientPermissions.schema, pageNotFound, validation, persistence, disabled], }).annotateMerge( OpenApi.annotations({ identifier: "createStatusPageComponent", @@ -1154,7 +1172,7 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") params: pageParams, payload: V2StatusPageComponentReorderParams, success: StatusPageComponentList, - error: [V2InsufficientPermissions.schema, pageNotFound, validation, persistence], + error: [V2InsufficientPermissions.schema, pageNotFound, validation, persistence, disabled], }).annotateMerge( OpenApi.annotations({ identifier: "reorderStatusPageComponents", @@ -1174,6 +1192,7 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") componentNotFound, validation, persistence, + disabled, ], }).annotateMerge( OpenApi.annotations({ @@ -1193,6 +1212,7 @@ export class V2StatusPagesApiGroup extends HttpApiGroup.make("statusPages") componentNotFound, validation, persistence, + disabled, ], }).annotateMerge( OpenApi.annotations({ @@ -1217,7 +1237,7 @@ export class V2StatusPageIncidentsApiGroup extends HttpApiGroup.make("statusPage HttpApiEndpoint.get("list", "/", { query: IncidentsQuery, success: StatusPageIncidentList, - error: [V2ParameterInvalid.schema, pageNotFound, persistence], + error: [V2ParameterInvalid.schema, pageNotFound, persistence, disabled], }).annotateMerge( OpenApi.annotations({ identifier: "listStatusPageIncidents", @@ -1231,7 +1251,7 @@ export class V2StatusPageIncidentsApiGroup extends HttpApiGroup.make("statusPage HttpApiEndpoint.post("create", "/", { payload: V2StatusPageIncidentCreateParams, success: V2StatusPageIncident, - error: [pageNotFound, validation, persistence], + error: [pageNotFound, validation, persistence, disabled], }).annotateMerge( OpenApi.annotations({ identifier: "createStatusPageIncident", @@ -1245,7 +1265,7 @@ export class V2StatusPageIncidentsApiGroup extends HttpApiGroup.make("statusPage HttpApiEndpoint.get("retrieve", "/:id", { params: { id: StatusPageIncidentPublicId }, success: V2StatusPageIncident, - error: [incidentNotFound, persistence], + error: [incidentNotFound, persistence, disabled], }).annotateMerge( OpenApi.annotations({ identifier: "getStatusPageIncident", @@ -1260,7 +1280,7 @@ export class V2StatusPageIncidentsApiGroup extends HttpApiGroup.make("statusPage params: { id: StatusPageIncidentPublicId }, payload: V2StatusPageIncidentUpdateParams, success: V2StatusPageIncident, - error: [incidentNotFound, pageNotFound, validation, persistence], + error: [incidentNotFound, pageNotFound, validation, persistence, disabled], }).annotateMerge( OpenApi.annotations({ identifier: "updateStatusPageIncident", @@ -1274,7 +1294,7 @@ export class V2StatusPageIncidentsApiGroup extends HttpApiGroup.make("statusPage HttpApiEndpoint.delete("delete", "/:id", { params: { id: StatusPageIncidentPublicId }, success: V2StatusPageIncidentDeleteResponse, - error: [incidentNotFound, pageNotFound, validation, persistence], + error: [incidentNotFound, pageNotFound, validation, persistence, disabled], }).annotateMerge( OpenApi.annotations({ identifier: "deleteStatusPageIncident", @@ -1289,7 +1309,7 @@ export class V2StatusPageIncidentsApiGroup extends HttpApiGroup.make("statusPage params: { id: StatusPageIncidentPublicId }, payload: V2StatusPageIncidentPostUpdateParams, success: V2StatusPageIncident, - error: [incidentNotFound, pageNotFound, validation, persistence], + error: [incidentNotFound, pageNotFound, validation, persistence, disabled], }).annotateMerge( OpenApi.annotations({ identifier: "postStatusPageIncidentUpdate", diff --git a/packages/domain/src/mcp-outputs/status-pages.ts b/packages/domain/src/mcp-outputs/status-pages.ts index 44cd5fc239..de01e9790e 100644 --- a/packages/domain/src/mcp-outputs/status-pages.ts +++ b/packages/domain/src/mcp-outputs/status-pages.ts @@ -92,6 +92,8 @@ export const GetStatusPageOutput = Schema.Struct({ export const StatusPageIncidentMutationOutput = Schema.Struct({ action: Schema.Literals(["created", "updated", "resolved", "scheduled"]), incident: StatusPageIncidentRow, + /** The incident's page is a draft: nothing on it is public, a published incident included. */ + pageDraft: Schema.Boolean, }) // --------------------------------------------------------------------------------------------- diff --git a/packages/domain/src/organization-feature-flags.ts b/packages/domain/src/organization-feature-flags.ts index 1fdab43b2d..527375d17f 100644 --- a/packages/domain/src/organization-feature-flags.ts +++ b/packages/domain/src/organization-feature-flags.ts @@ -27,11 +27,14 @@ export const OrganizationFeatureFlags = Schema.Struct({ releases: DisabledByDefaultFeatureFlag, /** Gates the scheduled security audit API; internal-only while it is built out. */ securityAudit: DisabledByDefaultFeatureFlag, + /** Gates customer status pages: the nav row, the `/status-pages` routes, the API and MCP tools. */ + statusPages: DisabledByDefaultFeatureFlag, }).pipe( Schema.encodeKeys({ aiAutoTriage: "aiautotriage", releases: "releases", securityAudit: "securityaudit", + statusPages: "statuspages", }), ) @@ -44,6 +47,7 @@ export const DISABLED_ORGANIZATION_FEATURE_FLAGS: OrganizationFeatureFlags = { aiAutoTriage: false, releases: false, securityAudit: false, + statusPages: false, } /** @@ -56,6 +60,7 @@ export const ENABLED_ORGANIZATION_FEATURE_FLAGS: OrganizationFeatureFlags = { aiAutoTriage: true, releases: true, securityAudit: true, + statusPages: true, } /** Decode Clerk metadata, falling back to every rollout disabled for non-object input. */